mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20255c9916 | ||
|
|
02aa1d67db | ||
|
|
e0e90af9fd | ||
|
|
94e7f91b7b | ||
|
|
00c7f0460a | ||
|
|
175da268dd | ||
|
|
f6f7fa704a | ||
|
|
4f5d25808f | ||
|
|
a05c583fb5 | ||
|
|
25efa62587 | ||
|
|
0d93ce0874 | ||
|
|
20cdd6653a | ||
|
|
862484c0d4 | ||
|
|
e1e481a1a7 | ||
|
|
aed3e6a273 | ||
|
|
b481a70458 | ||
|
|
2b7fd2002b | ||
|
|
ccccb2dd8c | ||
|
|
385e9e2aab | ||
|
|
48035872e1 | ||
|
|
d4594b9504 | ||
|
|
9ae440be6a | ||
|
|
fd2dffdda7 | ||
|
|
953d24f7f2 | ||
|
|
6dd8dd055a | ||
|
|
85112d809b | ||
|
|
e4b3cff5fa | ||
|
|
fb80403cd4 | ||
|
|
7ad19ab4c3 | ||
|
|
b1ff5e6b82 | ||
|
|
ee148a018b | ||
|
|
26b247c329 | ||
|
|
81168adad1 | ||
|
|
18dcc747d7 | ||
|
|
6056d20a13 | ||
|
|
112c0ec1ed | ||
|
|
688b954fb8 | ||
|
|
f5dde45c18 |
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.6.0
|
MARKETING_VERSION = 1.5.4
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationChannelsModel: LocationChannelsModel
|
let locationChannelsModel: LocationChannelsModel
|
||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let appChromeModel: AppChromeModel
|
let appChromeModel: AppChromeModel
|
||||||
let boardAlertsModel: BoardAlertsModel
|
|
||||||
|
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -92,24 +91,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
privateInboxModel: self.privateInboxModel
|
privateInboxModel: self.privateInboxModel
|
||||||
)
|
)
|
||||||
let chatViewModel = self.chatViewModel
|
|
||||||
self.boardAlertsModel = BoardAlertsModel(
|
|
||||||
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
|
||||||
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
|
||||||
dependencies: BoardAlertsModel.Dependencies(
|
|
||||||
isOwnPost: { post in
|
|
||||||
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
|
||||||
return !key.isEmpty && key == post.authorSigningKey
|
|
||||||
},
|
|
||||||
emitSystemLine: { content, geohash in
|
|
||||||
if geohash.isEmpty {
|
|
||||||
chatViewModel.addMeshOnlySystemMessage(content)
|
|
||||||
} else {
|
|
||||||
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
#if DEBUG
|
||||||
|
import BitLogger
|
||||||
|
#endif
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
@@ -40,10 +43,14 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(runtime.locationChannelsModel)
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
.environmentObject(runtime.peerListModel)
|
.environmentObject(runtime.peerListModel)
|
||||||
.environmentObject(runtime.appChromeModel)
|
.environmentObject(runtime.appChromeModel)
|
||||||
.environmentObject(runtime.boardAlertsModel)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
runtime.start()
|
||||||
|
#if DEBUG
|
||||||
|
// Arm the opt-in UDP log sink from persisted config (no-op
|
||||||
|
// until a collector host is set in the App Info sheet).
|
||||||
|
LogNetworkSink.shared.reloadConfiguration()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
runtime.handleOpenURL(url)
|
runtime.handleOpenURL(url)
|
||||||
|
|||||||
@@ -33,12 +33,18 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_bitchat-bulk._tcp</string>
|
||||||
|
</array>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
+497
-25788
File diff suppressed because it is too large
Load Diff
@@ -74,11 +74,11 @@ enum CommandInfo: String, Identifiable {
|
|||||||
if !isGeoPublic {
|
if !isGeoPublic {
|
||||||
commands.append(.pay)
|
commands.append(.pay)
|
||||||
}
|
}
|
||||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
// The processor rejects favorites, groups and mesh diagnostics in
|
||||||
// geohash contexts, so only suggest them where they work: mesh.
|
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ struct NostrProtocol {
|
|||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -257,22 +256,16 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
|
||||||
/// drop the note in step with a bridged board post's expiry.
|
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil,
|
nickname: String? = nil
|
||||||
expiresAt: Date? = nil
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if let expiresAt {
|
|
||||||
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
|
||||||
}
|
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -283,25 +276,7 @@ struct NostrProtocol {
|
|||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
|
||||||
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
|
||||||
/// key that signed the original.
|
|
||||||
static func createDeleteEvent(
|
|
||||||
ofEventID eventID: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .deletion,
|
|
||||||
tags: [["e", eventID]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
|||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
func encode() -> Data? {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||||
|
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||||
|
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
var big = value.bigEndian
|
var big = value.bigEndian
|
||||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||||
|
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||||
|
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||||
var cursor = data.startIndex
|
var cursor = data.startIndex
|
||||||
let end = data.endIndex
|
let end = data.endIndex
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
|||||||
for byte in value {
|
for byte in value {
|
||||||
size = (size << 8) | UInt64(byte)
|
size = (size << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
if size > UInt64(limit) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fileSize = size
|
fileSize = size
|
||||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
|||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
let proposedSize = content.count + value.count
|
let proposedSize = content.count + value.count
|
||||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
if proposedSize > limit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.append(contentsOf: value)
|
content.append(contentsOf: value)
|
||||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
guard !content.isEmpty else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||||
return BitchatFilePacket(
|
return BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
// Private groups (0x04/0x05 reserved by other features)
|
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
||||||
|
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
||||||
|
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
||||||
|
// Private groups
|
||||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
@@ -88,6 +91,8 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||||
|
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||||
case .groupInvite: return "groupInvite"
|
case .groupInvite: return "groupInvite"
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ enum Geohash {
|
|||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates a geohash string at any channel precision (1-12 characters).
|
|
||||||
/// - Parameter geohash: The geohash string to validate
|
|
||||||
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
|
||||||
static func isValidGeohash(_ geohash: String) -> Bool {
|
|
||||||
guard (1...12).contains(geohash.count) else { return false }
|
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - latitude: Latitude in degrees (-90...90)
|
/// - latitude: Latitude in degrees (-90...90)
|
||||||
|
|||||||
@@ -3,5 +3,9 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
static let localSupported: PeerCapabilities = {
|
||||||
|
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||||
|
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||||
|
return caps
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||||
|
/// pass the ceiling that was enforced against the accepted offer.
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
|
|||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
enum BLEIncomingFileValidator {
|
||||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
/// pass the ceiling enforced against the accepted offer.
|
||||||
|
static func validate(
|
||||||
|
payload: Data,
|
||||||
|
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||||
|
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
|
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||||
return .failure(.malformedPayload)
|
return .failure(.malformedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,21 +33,13 @@ final class BLEFragmentHandler {
|
|||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let env = environment
|
let env = environment
|
||||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
// Don't process our own fragments
|
||||||
|
|
||||||
// Sync replay legitimately hands us our own fragments back (the RSR
|
|
||||||
// ttl=0 restore path): after a relaunch the fragment store starts
|
|
||||||
// empty, so our sync filter doesn't cover them and peers re-offer
|
|
||||||
// them. Record them as seen — the next round's filter then covers
|
|
||||||
// them and the redelivery stops — but skip assembly: we authored
|
|
||||||
// the original, there is nothing to reassemble.
|
|
||||||
if peerID == env.localPeerID() {
|
if peerID == env.localPeerID() {
|
||||||
if header.isBroadcastFragment {
|
|
||||||
env.trackPacketSeen(packet)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||||
|
|
||||||
if header.isBroadcastFragment {
|
if header.isBroadcastFragment {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,16 +51,16 @@ struct BLEReceivePipeline {
|
|||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
// remote handover toward a relayed announce rides this same
|
// remote handover toward a relayed announce rides this same
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
// Ping/pong diagnostics ride it too: probes need the same
|
|
||||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
|
||||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
|
||||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||||
|
// Ping/pong diagnostics also ride it: probes need the same
|
||||||
|
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||||
|
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||||
|
|| packet.type == MessageType.nostrCarrier.rawValue
|
||||||
|| packet.type == MessageType.ping.rawValue
|
|| packet.type == MessageType.ping.rawValue
|
||||||
|| packet.type == MessageType.pong.rawValue
|
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil,
|
||||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
|
||||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
|
|
||||||
/// links) so the service can arm pending background connections against them
|
|
||||||
/// when the app leaves the foreground. Generic over the peripheral type so
|
|
||||||
/// the eviction/expiry logic is testable without CoreBluetooth.
|
|
||||||
final class BLERecentPeripheralCache<Peripheral> {
|
|
||||||
private struct Entry {
|
|
||||||
let peripheral: Peripheral
|
|
||||||
var lastSeen: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var entries: [String: Entry] = [:]
|
|
||||||
private let capacity: Int
|
|
||||||
private let maxAge: TimeInterval
|
|
||||||
|
|
||||||
init(
|
|
||||||
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
|
|
||||||
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
|
|
||||||
) {
|
|
||||||
self.capacity = capacity
|
|
||||||
self.maxAge = maxAge
|
|
||||||
}
|
|
||||||
|
|
||||||
var count: Int { entries.count }
|
|
||||||
|
|
||||||
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
|
|
||||||
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
|
|
||||||
guard entries.count > capacity else { return }
|
|
||||||
// Inserts overshoot capacity by at most one; evict the stalest entry
|
|
||||||
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
|
|
||||||
entries.removeValue(forKey: stalest.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-recently-seen peripherals eligible for a pending background
|
|
||||||
/// connect, freshest first, capped at `limit`. Expired entries are
|
|
||||||
/// pruned as a side effect.
|
|
||||||
func reconnectTargets(
|
|
||||||
now: Date,
|
|
||||||
limit: Int,
|
|
||||||
excluding: (String) -> Bool
|
|
||||||
) -> [(peripheralID: String, peripheral: Peripheral)] {
|
|
||||||
let cutoff = now.addingTimeInterval(-maxAge)
|
|
||||||
entries = entries.filter { $0.value.lastSeen >= cutoff }
|
|
||||||
guard limit > 0 else { return [] }
|
|
||||||
return entries
|
|
||||||
.filter { !excluding($0.key) }
|
|
||||||
.sorted { $0.value.lastSeen > $1.value.lastSeen }
|
|
||||||
.prefix(limit)
|
|
||||||
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,8 +90,6 @@ final class BLEService: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||||
private let meshTopology = MeshTopologyTracker()
|
private let meshTopology = MeshTopologyTracker()
|
||||||
// Route health for originated source routes; guarded by collectionsQueue.
|
|
||||||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
|
||||||
|
|
||||||
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
|
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
|
||||||
// inbound ping budget — keyed by the ingress link (the directly connected
|
// inbound ping budget — keyed by the ingress link (the directly connected
|
||||||
@@ -111,6 +109,10 @@ final class BLEService: NSObject {
|
|||||||
window: TransportConfig.meshPingInboundWindowSeconds
|
window: TransportConfig.meshPingInboundWindowSeconds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Route health for originated source routes; guarded by collectionsQueue.
|
||||||
|
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||||||
|
|
||||||
|
|
||||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||||
@@ -188,6 +190,8 @@ final class BLEService: NSObject {
|
|||||||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||||||
// File-transfer orchestration (queue hops stay in the environment closures)
|
// File-transfer orchestration (queue hops stay in the environment closures)
|
||||||
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
||||||
|
// Wi-Fi bulk data plane: BLE/Noise negotiates, AWDL carries large media
|
||||||
|
private lazy var wifiBulkService = WifiBulkTransferService(environment: makeWifiBulkEnvironment())
|
||||||
|
|
||||||
// MARK: - Gossip Sync
|
// MARK: - Gossip Sync
|
||||||
private var gossipSyncManager: GossipSyncManager?
|
private var gossipSyncManager: GossipSyncManager?
|
||||||
@@ -197,7 +201,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||||
private var maintenanceCounter = 0 // Track maintenance cycles
|
private var maintenanceCounter = 0 // Track maintenance cycles
|
||||||
private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes
|
|
||||||
/// Whether real CoreBluetooth managers were initialized. When false (unit
|
/// Whether real CoreBluetooth managers were initialized. When false (unit
|
||||||
/// tests), periodic mesh background work is not started — the maintenance
|
/// tests), periodic mesh background work is not started — the maintenance
|
||||||
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
||||||
@@ -208,9 +211,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - Connection budget & scheduling (central role)
|
// MARK: - Connection budget & scheduling (central role)
|
||||||
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
||||||
// Recently seen peripherals retained for background wake-on-proximity
|
|
||||||
// connects (bleQueue-confined, like the link state store)
|
|
||||||
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
|
|
||||||
|
|
||||||
// MARK: - Adaptive scanning duty-cycle
|
// MARK: - Adaptive scanning duty-cycle
|
||||||
private var scanDutyTimer: DispatchSourceTimer?
|
private var scanDutyTimer: DispatchSourceTimer?
|
||||||
@@ -316,6 +316,12 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Initialize gossip sync manager
|
// Initialize gossip sync manager
|
||||||
restartGossipManager()
|
restartGossipManager()
|
||||||
|
|
||||||
|
// Force single-threaded instantiation: unlike the packet handlers
|
||||||
|
// (touched only from messageQueue), the Wi-Fi bulk service is reached
|
||||||
|
// from the main actor too (cancelTransfer/stopServices), and lazy
|
||||||
|
// vars are not thread-safe.
|
||||||
|
_ = wifiBulkService
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartGossipManager() {
|
private func restartGossipManager() {
|
||||||
@@ -564,6 +570,9 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
|
// Tear down any Wi-Fi bulk listeners/connections deterministically
|
||||||
|
wifiBulkService.stop()
|
||||||
|
|
||||||
// Send leave message synchronously to ensure delivery
|
// Send leave message synchronously to ensure delivery
|
||||||
var leavePacket = BitchatPacket(
|
var leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
@@ -783,6 +792,9 @@ final class BLEService: NSObject {
|
|||||||
// MARK: Messaging
|
// MARK: Messaging
|
||||||
|
|
||||||
func cancelTransfer(_ transferId: String) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
|
// A transfer may be riding the Wi-Fi bulk channel instead of the BLE
|
||||||
|
// fragment scheduler; cancelling both is safe (each no-ops on miss).
|
||||||
|
wifiBulkService.cancelTransfer(transferId: transferId)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -858,10 +870,72 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
// Encode with the Wi-Fi bulk ceiling; whether the payload also
|
||||||
|
// fits the BLE caps decides what a fallback may do.
|
||||||
|
guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let fitsBLECaps = filePacket.encode() != nil
|
||||||
|
|
||||||
|
// Normalize to the short routing ID (SHA256-derived 16-hex) once.
|
||||||
|
// Stable/verified private chats address the peer by its full
|
||||||
|
// 64-hex Noise key, but the Noise session and the negotiation
|
||||||
|
// packet are keyed by the short ID — so the capability/session
|
||||||
|
// eligibility checks (and the Wi-Fi offer) must all use it, or a
|
||||||
|
// 64-hex key makes `hasEstablishedSession` return false and Wi-Fi
|
||||||
|
// is never selected even when the peer advertises `.wifiBulk`.
|
||||||
|
let routingID = peerID.toShort()
|
||||||
|
|
||||||
|
let candidate = self.wifiBulkSendCandidate(payloadBytes: payload.count, to: routingID)
|
||||||
|
if WifiBulkPolicy.shouldOffer(candidate) {
|
||||||
|
self.wifiBulkService.sendFile(payload: payload, to: routingID, transferId: transferId) { [weak self] in
|
||||||
|
self?.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: fitsBLECaps)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard fitsBLECaps else {
|
||||||
|
// Wi-Fi-only payload with no eligible Wi-Fi path.
|
||||||
|
SecureLogger.error("❌ File exceeds BLE caps and Wi-Fi bulk is unavailable", category: .session)
|
||||||
|
self.surfaceUndeliverableTransfer(transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the Wi-Fi bulk eligibility candidate for a private send.
|
||||||
|
///
|
||||||
|
/// Normalizes `peerID` to its short routing ID first: stable/verified
|
||||||
|
/// private chats address the peer by its full 64-hex Noise key, but the
|
||||||
|
/// Noise session, advertised capabilities, and connection state are all
|
||||||
|
/// keyed by the short (SHA256-derived 16-hex) ID. Resolving them with the
|
||||||
|
/// 64-hex key would miss the session (`hasEstablishedSession` returns
|
||||||
|
/// false), so Wi-Fi would never be offered even to a directly-connected
|
||||||
|
/// `.wifiBulk` peer.
|
||||||
|
private func wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate {
|
||||||
|
let routingID = peerID.toShort()
|
||||||
|
return WifiBulkPolicy.SendCandidate(
|
||||||
|
payloadBytes: payloadBytes,
|
||||||
|
peerCapabilities: peerCapabilities(routingID),
|
||||||
|
isDirectlyConnected: isPeerConnected(routingID),
|
||||||
|
hasEstablishedNoiseSession: noiseService.hasEstablishedSession(with: routingID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BLE fragmentation path for private file transfers; also the fallback
|
||||||
|
/// target when a Wi-Fi bulk attempt declines, times out, or errors.
|
||||||
|
/// May be invoked from the Wi-Fi bulk queue.
|
||||||
|
private func sendFilePrivateViaBLE(payload: Data, to peerID: PeerID, transferId: String, fitsBLECaps: Bool) {
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard fitsBLECaps else {
|
||||||
|
// A >1 MiB payload was negotiated for Wi-Fi and the channel
|
||||||
|
// failed: BLE cannot carry it, surface the normal failure path.
|
||||||
|
SecureLogger.error("❌ Wi-Fi bulk failed and payload exceeds BLE caps (\(payload.count) bytes)", category: .session)
|
||||||
|
self.surfaceUndeliverableTransfer(transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
|
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
|
||||||
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
||||||
let targetID = peerID.toShort()
|
let targetID = peerID.toShort()
|
||||||
@@ -890,6 +964,13 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drives the progress bus through started→cancelled so the UI clears a
|
||||||
|
/// transfer that no transport can carry.
|
||||||
|
private func surfaceUndeliverableTransfer(_ transferId: String) {
|
||||||
|
TransferProgressManager.shared.start(id: transferId, totalFragments: 1)
|
||||||
|
TransferProgressManager.shared.cancel(id: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||||||
@@ -1275,6 +1356,57 @@ final class BLEService: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the Wi-Fi bulk service environment. Noise encryption and packet
|
||||||
|
/// dispatch stay on the message queue; incoming payloads re-enter the
|
||||||
|
/// normal file-transfer pipeline as if a fully assembled packet arrived.
|
||||||
|
private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment {
|
||||||
|
WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { [weak self] typedPayload, peerID in
|
||||||
|
guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false }
|
||||||
|
self.messageQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
do {
|
||||||
|
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { [weak self] peerID in
|
||||||
|
self?.isPeerConnected(peerID) ?? false
|
||||||
|
},
|
||||||
|
deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in
|
||||||
|
self?.messageQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
senderID: Data(hexString: peerID.toShort().id) ?? Data(),
|
||||||
|
recipientID: self.myPeerIDData,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 0,
|
||||||
|
version: 2
|
||||||
|
)
|
||||||
|
self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progressStart: { transferId, totalChunks in
|
||||||
|
TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks)
|
||||||
|
},
|
||||||
|
progressChunkSent: { transferId in
|
||||||
|
TransferProgressManager.shared.recordFragmentSent(id: transferId)
|
||||||
|
},
|
||||||
|
progressReset: { transferId in
|
||||||
|
TransferProgressManager.shared.reset(id: transferId)
|
||||||
|
},
|
||||||
|
progressCancel: { transferId in
|
||||||
|
TransferProgressManager.shared.cancel(id: transferId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||||||
|
|
||||||
@@ -1519,14 +1651,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
assembler: assembler
|
assembler: assembler
|
||||||
)
|
)
|
||||||
linkStateStore.setPeripheralState(restoredState, for: identifier)
|
linkStateStore.setPeripheralState(restoredState, for: identifier)
|
||||||
|
|
||||||
// Restored peripherals are the freshest wake-on-proximity
|
|
||||||
// candidates we have after a relaunch — without this the cache
|
|
||||||
// starts empty and backgrounding right after a restore arms
|
|
||||||
// nothing. Service rediscovery for restored-connected links waits
|
|
||||||
// for poweredOn: CoreBluetooth drops commands issued during
|
|
||||||
// restoration (API MISUSE warnings).
|
|
||||||
recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
captureBluetoothStatus(context: "central-restore")
|
captureBluetoothStatus(context: "central-restore")
|
||||||
@@ -1542,17 +1666,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
|
|
||||||
switch central.state {
|
switch central.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
// Links restored as connected have no characteristic in the new
|
|
||||||
// process; without rediscovery they sit connected-but-unusable
|
|
||||||
// until the peer disconnects. Runs here (not willRestoreState)
|
|
||||||
// because commands issued before poweredOn are dropped.
|
|
||||||
for state in linkStateStore.peripheralStates where state.isConnected
|
|
||||||
&& state.characteristic == nil
|
|
||||||
&& state.peripheral.state == .connected {
|
|
||||||
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session)
|
|
||||||
state.peripheral.discoverServices([BLEService.serviceUUID])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start scanning - use allow duplicates for faster discovery when active
|
// Start scanning - use allow duplicates for faster discovery when active
|
||||||
startScanning()
|
startScanning()
|
||||||
|
|
||||||
@@ -1632,9 +1745,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
isConnectable: isConnectable,
|
isConnectable: isConnectable,
|
||||||
discoveredAt: Date()
|
discoveredAt: Date()
|
||||||
)
|
)
|
||||||
if isConnectable {
|
|
||||||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
|
|
||||||
}
|
|
||||||
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
|
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
|
||||||
|
|
||||||
switch connectionScheduler.handleDiscovery(
|
switch connectionScheduler.handleDiscovery(
|
||||||
@@ -1661,15 +1771,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// A connect completing while backgrounded is the wake-on-proximity
|
|
||||||
// path doing its job — worth an info line for field verification.
|
|
||||||
if !isAppActive {
|
|
||||||
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Update state to connected
|
// Update state to connected
|
||||||
linkStateStore.markConnected(peripheral)
|
linkStateStore.markConnected(peripheral)
|
||||||
|
|
||||||
@@ -1694,26 +1796,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
if error != nil {
|
if error != nil {
|
||||||
connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
|
connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retain the handle: a dropped link is the best wake-on-proximity
|
|
||||||
// candidate if the app backgrounds before the peer returns.
|
|
||||||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date())
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// Link lost while backgrounded (peer walked away): re-arm a pending
|
|
||||||
// connect during this wake window so the peer's return wakes us again.
|
|
||||||
// Delayed past the disconnect-settle window to avoid reconnect thrash
|
|
||||||
// at range edge.
|
|
||||||
if !isAppActive {
|
|
||||||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
|
|
||||||
guard let self, !self.isAppActive else { return }
|
|
||||||
// Reserve 0: use the slot this disconnect freed even in a
|
|
||||||
// dense mesh, so the lost peer can wake us when it returns.
|
|
||||||
self.armPendingBackgroundConnects(slotReserve: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Clean up references and peer mappings
|
// Clean up references and peer mappings
|
||||||
_ = linkStateStore.removePeripheral(peripheralID)
|
_ = linkStateStore.removePeripheral(peripheralID)
|
||||||
if let peerID {
|
if let peerID {
|
||||||
@@ -1841,18 +1924,6 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
if !self.isAppActive {
|
|
||||||
// Backgrounded: leave the connect pending. iOS never expires
|
|
||||||
// it — the controller completes it whenever the peer comes
|
|
||||||
// back into range, waking the app (state restoration relaunches
|
|
||||||
// us if we were terminated). Foreground return cancels stale
|
|
||||||
// pendings via cancelStalePendingConnects().
|
|
||||||
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
|
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
|
||||||
central.cancelPeripheralConnection(peripheral)
|
central.cancelPeripheralConnection(peripheral)
|
||||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||||
@@ -1932,6 +2003,34 @@ extension BLEService {
|
|||||||
cachedServiceUUIDs: cachedServiceUUIDs
|
cachedServiceUUIDs: cachedServiceUUIDs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The internal Noise service, so tests can drive a real handshake and
|
||||||
|
/// establish a session keyed by a chosen (short) routing ID.
|
||||||
|
var _test_noiseService: NoiseEncryptionService { noiseService }
|
||||||
|
|
||||||
|
/// Registers a directly-connected peer with the given advertised
|
||||||
|
/// capabilities, keyed by its short routing ID (as production does).
|
||||||
|
func _test_registerConnectedPeer(_ peerID: PeerID, capabilities: PeerCapabilities) {
|
||||||
|
let shortID = peerID.toShort()
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
|
peerID: shortID,
|
||||||
|
nickname: "TestPeer_\(shortID.id.prefix(4))",
|
||||||
|
isConnected: true,
|
||||||
|
noisePublicKey: peerID.noiseKey,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: Date(),
|
||||||
|
capabilities: capabilities
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the production Wi-Fi bulk eligibility resolution (including peer-ID
|
||||||
|
/// normalization) for the given payload size and recipient.
|
||||||
|
func _test_wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate {
|
||||||
|
wifiBulkSendCandidate(payloadBytes: payloadBytes, to: peerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -2604,7 +2703,7 @@ extension BLEService {
|
|||||||
|
|
||||||
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let route = BLESourceRouteOriginationPolicy.route(
|
let decision = BLESourceRouteOriginationPolicy.decide(
|
||||||
for: packet,
|
for: packet,
|
||||||
to: recipient,
|
to: recipient,
|
||||||
localPeerIDData: myPeerIDData,
|
localPeerIDData: myPeerIDData,
|
||||||
@@ -2616,7 +2715,19 @@ extension BLEService {
|
|||||||
},
|
},
|
||||||
computeRoute: { self.computeRoute(to: $0) }
|
computeRoute: { self.computeRoute(to: $0) }
|
||||||
)
|
)
|
||||||
guard let route else { return packet }
|
let route: [Data]
|
||||||
|
switch decision {
|
||||||
|
case .flood(let reason):
|
||||||
|
// Only log the interesting directed cases; a plain broadcast keeps
|
||||||
|
// flood by design and would spam the origination path.
|
||||||
|
if reason != .broadcast {
|
||||||
|
SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh)
|
||||||
|
}
|
||||||
|
return packet
|
||||||
|
case .route(let hops):
|
||||||
|
SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh)
|
||||||
|
route = hops
|
||||||
|
}
|
||||||
// Create new packet with route applied and version upgraded to 2
|
// Create new packet with route applied and version upgraded to 2
|
||||||
let routedPacket = BitchatPacket(
|
let routedPacket = BitchatPacket(
|
||||||
type: packet.type,
|
type: packet.type,
|
||||||
@@ -2706,7 +2817,7 @@ extension BLEService {
|
|||||||
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
||||||
guard packet.recipientID == myPeerIDData else { return }
|
guard packet.recipientID == myPeerIDData else { return }
|
||||||
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
||||||
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))…", category: .mesh)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -2714,7 +2825,7 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
guard allowed else {
|
guard allowed else {
|
||||||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||||||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .mesh)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2748,6 +2859,7 @@ extension BLEService {
|
|||||||
rttMs: max(0, rttMs),
|
rttMs: max(0, rttMs),
|
||||||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||||||
)
|
)
|
||||||
|
SecureLogger.info("[PING] pong from \(peerID.id.prefix(8))… rtt=\(result.rttMs)ms hops=\(result.hops)", category: .mesh)
|
||||||
Task { @MainActor in pending.completion(result) }
|
Task { @MainActor in pending.completion(result) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2756,11 +2868,15 @@ extension BLEService {
|
|||||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||||
refreshLocalTopology()
|
refreshLocalTopology()
|
||||||
if let route = computeRoute(to: peerID) {
|
if let route = computeRoute(to: peerID) {
|
||||||
return route.compactMap { PeerID(routingData: $0) }
|
let path = route.compactMap { PeerID(routingData: $0) }
|
||||||
|
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh)
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
// Confirmed claims can lag a brand-new link (the peer's next announce
|
// Confirmed claims can lag a brand-new link (the peer's next announce
|
||||||
// hasn't arrived yet); a live direct connection is still a known path.
|
// hasn't arrived yet); a live direct connection is still a known path.
|
||||||
return isPeerConnected(peerID) ? [] : nil
|
let direct = isPeerConnected(peerID)
|
||||||
|
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… \(direct ? "direct (0 hops)" : "no known path")", category: .mesh)
|
||||||
|
return direct ? [] : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||||||
@@ -2906,9 +3022,11 @@ extension BLEService {
|
|||||||
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||||||
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||||||
prekeyID = prekey.id
|
prekeyID = prekey.id
|
||||||
|
SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))…", category: .session)
|
||||||
} else {
|
} else {
|
||||||
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||||
prekeyID = nil
|
prekeyID = nil
|
||||||
|
SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
let envelope = CourierEnvelope(
|
let envelope = CourierEnvelope(
|
||||||
recipientTag: CourierEnvelope.recipientTag(
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
@@ -3002,6 +3120,7 @@ extension BLEService {
|
|||||||
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
|
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
|
||||||
if opened.consumedPrekey {
|
if opened.consumedPrekey {
|
||||||
let replenished = noiseService.replenishPrekeysIfNeeded()
|
let replenished = noiseService.replenishPrekeysIfNeeded()
|
||||||
|
SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session)
|
||||||
sendPrekeyBundle(force: replenished)
|
sendPrekeyBundle(force: replenished)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3233,7 +3352,7 @@ extension BLEService {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let signingKey else {
|
guard let signingKey else {
|
||||||
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
|
SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||||||
@@ -3244,11 +3363,13 @@ extension BLEService {
|
|||||||
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
|
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
|
||||||
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
|
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
|
||||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||||
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
|
SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if prekeyBundleStore.ingest(bundle) {
|
if prekeyBundleStore.ingest(bundle) {
|
||||||
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
|
SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
}
|
}
|
||||||
@@ -3472,12 +3593,11 @@ extension BLEService {
|
|||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
cancelStalePendingConnects()
|
|
||||||
logBluetoothStatus("became-active")
|
logBluetoothStatus("became-active")
|
||||||
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
|
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
|
||||||
// No Local Name; nothing to refresh for advertising policy
|
// No Local Name; nothing to refresh for advertising policy
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func appDidEnterBackground() {
|
@objc private func appDidEnterBackground() {
|
||||||
isAppActive = false
|
isAppActive = false
|
||||||
// Restart scanning without allow duplicates in background
|
// Restart scanning without allow duplicates in background
|
||||||
@@ -3485,7 +3605,6 @@ extension BLEService {
|
|||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
armPendingBackgroundConnects()
|
|
||||||
// Backgrounding may precede a kill; flush the public-history archive
|
// Backgrounding may precede a kill; flush the public-history archive
|
||||||
// outside its 30s maintenance cadence.
|
// outside its 30s maintenance cadence.
|
||||||
gossipSyncManager?.persistNow()
|
gossipSyncManager?.persistNow()
|
||||||
@@ -3493,81 +3612,6 @@ extension BLEService {
|
|||||||
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||||||
// No Local Name; nothing to refresh for advertising policy
|
// No Local Name; nothing to refresh for advertising policy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue indefinite `connect()` requests to recently seen peripherals on
|
|
||||||
/// backgrounding. Pending connects live in the Bluetooth controller's
|
|
||||||
/// allowlist — no scanning and no app CPU — and complete whenever a peer
|
|
||||||
/// comes into range, waking (or relaunching) the app. A couple of central
|
|
||||||
/// slots stay reserved for connects driven by live background discovery —
|
|
||||||
/// except on the disconnect re-arm path, which may consume the slot the
|
|
||||||
/// disconnect itself just freed (a dense mesh with 4+ remaining links
|
|
||||||
/// would otherwise compute a zero budget and never re-arm the lost peer).
|
|
||||||
private func armPendingBackgroundConnects(
|
|
||||||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
|
||||||
) {
|
|
||||||
bleQueue.async { [weak self] in
|
|
||||||
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
|
|
||||||
let budget = TransportConfig.bleMaxCentralLinks
|
|
||||||
- slotReserve
|
|
||||||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
|
||||||
let now = Date()
|
|
||||||
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
|
|
||||||
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
|
|
||||||
return state?.isConnected == true || state?.isConnecting == true
|
|
||||||
}
|
|
||||||
guard !targets.isEmpty else { return }
|
|
||||||
for target in targets {
|
|
||||||
// lastConnectionAttempt stays nil: an indefinite pending connect
|
|
||||||
// has no attempt clock, and nil marks it always-stale so
|
|
||||||
// cancelStalePendingConnects() reclaims it on foreground even
|
|
||||||
// after a quick background→foreground bounce.
|
|
||||||
self.linkStateStore.setPeripheralState(
|
|
||||||
BLEPeripheralLinkState(
|
|
||||||
peripheral: target.peripheral,
|
|
||||||
characteristic: nil,
|
|
||||||
peerID: nil,
|
|
||||||
isConnecting: true,
|
|
||||||
isConnected: false,
|
|
||||||
lastConnectionAttempt: nil,
|
|
||||||
assembler: NotificationStreamAssembler()
|
|
||||||
),
|
|
||||||
for: target.peripheralID
|
|
||||||
)
|
|
||||||
target.peripheral.delegate = self
|
|
||||||
central.connect(target.peripheral, options: [
|
|
||||||
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
|
||||||
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
|
||||||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
|
||||||
])
|
|
||||||
}
|
|
||||||
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Foreground restores normal connection management: pending connects
|
|
||||||
/// older than the connect timeout (including ones rebuilt by state
|
|
||||||
/// restoration after a relaunch) are cancelled so live scanning and the
|
|
||||||
/// scheduler take over. Anything still nearby is rediscovered within
|
|
||||||
/// seconds by the allow-duplicates foreground scan.
|
|
||||||
private func cancelStalePendingConnects() {
|
|
||||||
bleQueue.async { [weak self] in
|
|
||||||
guard let self, let central = self.centralManager else { return }
|
|
||||||
let now = Date()
|
|
||||||
var cancelled = 0
|
|
||||||
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
|
|
||||||
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
|
|
||||||
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
|
|
||||||
let peripheralID = state.peripheral.identifier.uuidString
|
|
||||||
central.cancelPeripheralConnection(state.peripheral)
|
|
||||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
|
||||||
cancelled += 1
|
|
||||||
}
|
|
||||||
if cancelled > 0 {
|
|
||||||
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
|
|
||||||
self.tryConnectFromQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: Private Message Handling
|
// MARK: Private Message Handling
|
||||||
@@ -3916,16 +3960,6 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// The maintenance timer is suspended with the app, so a packet arriving
|
|
||||||
// while backgrounded means the radio woke us — use the wake window to
|
|
||||||
// run the announce/flush/drain pass the timer would have run.
|
|
||||||
if !isAppActive {
|
|
||||||
bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
// Process by type
|
// Process by type
|
||||||
switch context.messageType {
|
switch context.messageType {
|
||||||
case .announce:
|
case .announce:
|
||||||
@@ -3952,15 +3986,12 @@ extension BLEService {
|
|||||||
case .courierEnvelope:
|
case .courierEnvelope:
|
||||||
handleCourierEnvelope(packet, from: peerID)
|
handleCourierEnvelope(packet, from: peerID)
|
||||||
|
|
||||||
case .groupMessage:
|
|
||||||
handleGroupMessage(packet, from: senderID)
|
|
||||||
|
|
||||||
case .prekeyBundle:
|
case .prekeyBundle:
|
||||||
handlePrekeyBundle(packet, from: senderID)
|
handlePrekeyBundle(packet, from: senderID)
|
||||||
|
|
||||||
case .boardPost:
|
case .groupMessage:
|
||||||
// Invalid or deleted posts must not spread; skip the relay step.
|
handleGroupMessage(packet, from: senderID)
|
||||||
guard handleBoardPost(packet, from: senderID) else { return }
|
|
||||||
case .nostrCarrier:
|
case .nostrCarrier:
|
||||||
handleNostrCarrier(packet, from: peerID)
|
handleNostrCarrier(packet, from: peerID)
|
||||||
|
|
||||||
@@ -3973,6 +4004,10 @@ extension BLEService {
|
|||||||
case .pong:
|
case .pong:
|
||||||
handleMeshPong(packet, from: senderID)
|
handleMeshPong(packet, from: senderID)
|
||||||
|
|
||||||
|
case .boardPost:
|
||||||
|
// Invalid or deleted posts must not spread; skip the relay step.
|
||||||
|
guard handleBoardPost(packet, from: senderID) else { return }
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
handleLeave(packet, from: senderID)
|
||||||
|
|
||||||
@@ -4356,8 +4391,21 @@ extension BLEService {
|
|||||||
self?.noiseService.clearSession(for: peerID)
|
self?.noiseService.clearSession(for: peerID)
|
||||||
},
|
},
|
||||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||||
|
guard let self = self else { return }
|
||||||
|
// Wi-Fi bulk negotiation is a transport concern; consume it
|
||||||
|
// here instead of surfacing it to the UI layer.
|
||||||
|
switch type {
|
||||||
|
case .bulkTransferOffer:
|
||||||
|
self.wifiBulkService.handleOfferPayload(payload, from: peerID)
|
||||||
|
return
|
||||||
|
case .bulkTransferResponse:
|
||||||
|
self.wifiBulkService.handleResponsePayload(payload, from: peerID)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
// Single main-actor hop delivering `.noisePayloadReceived`.
|
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||||||
self?.notifyUI { [weak self] in
|
self.notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -4422,7 +4470,6 @@ extension BLEService {
|
|||||||
|
|
||||||
private func performMaintenance() {
|
private func performMaintenance() {
|
||||||
maintenanceCounter += 1
|
maintenanceCounter += 1
|
||||||
lastMaintenanceAt = Date()
|
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
||||||
@@ -4487,18 +4534,6 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// Catch-up maintenance for background wake windows (bleQueue-confined).
|
|
||||||
/// Rate-limited to the normal maintenance cadence so a burst of inbound
|
|
||||||
/// packets during one wake still runs at most one extra pass.
|
|
||||||
private func performBackgroundWakeMaintenanceIfStale() {
|
|
||||||
guard meshBackgroundEnabled,
|
|
||||||
!isAppActive,
|
|
||||||
Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
|
|
||||||
performMaintenance()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func checkPeerConnectivity() {
|
private func checkPeerConnectivity() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
|
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
|
||||||
|
|||||||
@@ -4,8 +4,27 @@ import Foundation
|
|||||||
/// Decides whether an outbound directed packet should carry a v2 source
|
/// Decides whether an outbound directed packet should carry a v2 source
|
||||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
||||||
enum BLESourceRouteOriginationPolicy {
|
enum BLESourceRouteOriginationPolicy {
|
||||||
/// Returns the intermediate-hop route to attach, or nil to keep the
|
/// Why a packet kept flood/direct-write behavior instead of routing.
|
||||||
/// current flood/direct-write behavior unchanged.
|
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
|
||||||
|
enum FloodReason: String {
|
||||||
|
case relayedNotOriginator = "not originator"
|
||||||
|
case broadcast = "broadcast recipient"
|
||||||
|
case noTTLHeadroom = "link-local ttl"
|
||||||
|
case recipientDirect = "recipient direct"
|
||||||
|
case routeSuppressed = "route suppressed→flood"
|
||||||
|
case noPath = "no v2 path"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The routing decision for an originated packet.
|
||||||
|
enum Decision: Equatable {
|
||||||
|
/// Keep flood/direct-write behavior unchanged; carries the reason.
|
||||||
|
case flood(FloodReason)
|
||||||
|
/// Originate a v2 source route over these intermediate hops.
|
||||||
|
case route([Data])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether to originate a v2 source route (with its hops) or keep
|
||||||
|
/// flood/direct-write, with the reason for the latter.
|
||||||
///
|
///
|
||||||
/// Routes are only originated when every gate passes:
|
/// Routes are only originated when every gate passes:
|
||||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||||
@@ -19,22 +38,22 @@ enum BLESourceRouteOriginationPolicy {
|
|||||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||||
/// routed send, and
|
/// routed send, and
|
||||||
/// - the topology yields a complete path.
|
/// - the topology yields a complete path.
|
||||||
static func route(
|
static func decide(
|
||||||
for packet: BitchatPacket,
|
for packet: BitchatPacket,
|
||||||
to recipient: PeerID,
|
to recipient: PeerID,
|
||||||
localPeerIDData: Data,
|
localPeerIDData: Data,
|
||||||
isRecipientConnected: (PeerID) -> Bool,
|
isRecipientConnected: (PeerID) -> Bool,
|
||||||
shouldAttemptRoute: (PeerID) -> Bool,
|
shouldAttemptRoute: (PeerID) -> Bool,
|
||||||
computeRoute: (PeerID) -> [Data]?
|
computeRoute: (PeerID) -> [Data]?
|
||||||
) -> [Data]? {
|
) -> Decision {
|
||||||
guard packet.senderID == localPeerIDData else { return nil }
|
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
|
||||||
guard let recipientData = packet.recipientID,
|
guard let recipientData = packet.recipientID,
|
||||||
recipientData.count == 8,
|
recipientData.count == 8,
|
||||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
|
||||||
guard packet.ttl > 1 else { return nil }
|
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
|
||||||
guard !isRecipientConnected(recipient) else { return nil }
|
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
|
||||||
guard shouldAttemptRoute(recipient) else { return nil }
|
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
|
||||||
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
|
||||||
return route
|
return .route(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
//
|
|
||||||
// BoardAlertsModel.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Turns newly arriving board posts into local, scope-matched chat alerts.
|
|
||||||
/// Everything here is derived from posts the mesh already synced — no extra
|
|
||||||
/// wire traffic, nothing another peer can't already see.
|
|
||||||
///
|
|
||||||
/// - Urgent, recent pins get one system line in the matching chat (geo pin →
|
|
||||||
/// that geohash's timeline, mesh pin → mesh chat), collapsed when several
|
|
||||||
/// arrive together.
|
|
||||||
/// - Every other new pin just marks the header's pin icon until the notices
|
|
||||||
/// sheet is opened.
|
|
||||||
@MainActor
|
|
||||||
final class BoardAlertsModel: ObservableObject {
|
|
||||||
struct Dependencies {
|
|
||||||
/// Own posts never alert; the author already knows.
|
|
||||||
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
|
|
||||||
/// Appends a local system line to a scope's chat timeline
|
|
||||||
/// (geohash, or "" for mesh chat).
|
|
||||||
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
|
|
||||||
var now: () -> Date = Date.init
|
|
||||||
/// Schedules the collapsed flush of pending urgent alerts; tests
|
|
||||||
/// inject a synchronous hook.
|
|
||||||
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Posts older than this at arrival are backfilled history carried in by
|
|
||||||
/// a peer, not something happening now; they badge but never line the chat.
|
|
||||||
static let inlineRecencyWindow: TimeInterval = 30 * 60
|
|
||||||
/// Urgent arrivals within this window collapse into one line.
|
|
||||||
static let collapseDelaySeconds: TimeInterval = 4
|
|
||||||
private static let alertContentMaxChars = 120
|
|
||||||
|
|
||||||
/// Unseen new pins by postID (hex) → geohash scope, cleared when the
|
|
||||||
/// notices sheet opens.
|
|
||||||
@Published private(set) var unseenPostScopes: [String: String] = [:]
|
|
||||||
|
|
||||||
/// PostIDs already handled this session, so store eviction/re-sync churn
|
|
||||||
/// can't re-alert. Bounded by session wire volume (32-byte strings).
|
|
||||||
private var handledPostIDs = Set<String>()
|
|
||||||
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
|
|
||||||
private var flushScheduled = false
|
|
||||||
private let dependencies: Dependencies
|
|
||||||
private var cancellable: AnyCancellable?
|
|
||||||
private var wipeCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
private enum Strings {
|
|
||||||
static func urgentSingle(author: String, content: String) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
|
|
||||||
locale: .current,
|
|
||||||
author, content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func urgentCollapsed(_ count: Int) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
|
|
||||||
locale: .current,
|
|
||||||
count
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
arrivals: AnyPublisher<BoardPostPacket, Never>,
|
|
||||||
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
|
|
||||||
dependencies: Dependencies
|
|
||||||
) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
cancellable = arrivals
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] post in
|
|
||||||
self?.handleArrival(post)
|
|
||||||
}
|
|
||||||
wipeCancellable = wipes
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] in
|
|
||||||
self?.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unseenCount(forGeohash geohash: String) -> Int {
|
|
||||||
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks pins in the given scopes as seen — only the scopes the notices
|
|
||||||
/// sheet actually shows, so unseen pins for other geohash channels keep
|
|
||||||
/// their badge until visited.
|
|
||||||
func markSeen(forScopes scopes: Set<String>) {
|
|
||||||
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
|
|
||||||
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panic wipe: drop everything derived from pre-wipe posts, including
|
|
||||||
/// urgent lines still waiting on the collapse flush.
|
|
||||||
func reset() {
|
|
||||||
pendingUrgent.removeAll()
|
|
||||||
handledPostIDs.removeAll()
|
|
||||||
guard !unseenPostScopes.isEmpty else { return }
|
|
||||||
unseenPostScopes.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleArrival(_ post: BoardPostPacket) {
|
|
||||||
let postID = post.postID.hexEncodedString()
|
|
||||||
guard !handledPostIDs.contains(postID) else { return }
|
|
||||||
handledPostIDs.insert(postID)
|
|
||||||
guard !dependencies.isOwnPost(post) else { return }
|
|
||||||
|
|
||||||
unseenPostScopes[postID] = post.geohash
|
|
||||||
|
|
||||||
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
|
||||||
guard post.isUrgent,
|
|
||||||
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingUrgent[post.geohash, default: []].append(post)
|
|
||||||
if !flushScheduled {
|
|
||||||
flushScheduled = true
|
|
||||||
dependencies.scheduleFlush { [weak self] in
|
|
||||||
self?.flushPendingUrgent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func flushPendingUrgent() {
|
|
||||||
flushScheduled = false
|
|
||||||
let pending = pendingUrgent
|
|
||||||
pendingUrgent.removeAll()
|
|
||||||
for (geohash, posts) in pending {
|
|
||||||
guard let first = posts.first else { continue }
|
|
||||||
let line: String
|
|
||||||
if posts.count == 1 {
|
|
||||||
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
|
||||||
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
|
|
||||||
} else {
|
|
||||||
line = Strings.urgentCollapsed(posts.count)
|
|
||||||
}
|
|
||||||
dependencies.emitSystemLine(line, geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func truncated(_ content: String) -> String {
|
|
||||||
guard content.count > alertContentMaxChars else { return content }
|
|
||||||
return content.prefix(alertContentMaxChars) + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,28 +20,17 @@ final class BoardManager: ObservableObject {
|
|||||||
|
|
||||||
private let transport: Transport
|
private let transport: Transport
|
||||||
private let store: BoardStore
|
private let store: BoardStore
|
||||||
/// Publishes a bridged kind-1 note (expiring with the board post via
|
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
|
||||||
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
|
||||||
/// was skipped.
|
|
||||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String?
|
|
||||||
/// Requests NIP-09 deletion of a previously bridged note.
|
|
||||||
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
|
|
||||||
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
|
|
||||||
/// after a relaunch a delete still tombstones the board copy, but the
|
|
||||||
/// Nostr copy is left to expire with relay retention.
|
|
||||||
private var bridgedEventIDs: [Data: String] = [:]
|
|
||||||
private var cancellable: AnyCancellable?
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
store: BoardStore = .shared,
|
store: BoardStore = .shared,
|
||||||
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
|
publishToNostr: ((String, String, String) -> Void)? = nil
|
||||||
deleteFromNostr: ((String, String) -> Void)? = nil
|
|
||||||
) {
|
) {
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.store = store
|
self.store = store
|
||||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||||
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
|
||||||
cancellable = store.$postsSnapshot
|
cancellable = store.$postsSnapshot
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] snapshot in
|
.sink { [weak self] snapshot in
|
||||||
@@ -126,10 +115,10 @@ final class BoardManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||||
|
|
||||||
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
|
||||||
// online users see them. Remember the event id for merged deletes.
|
// location notes so online users see them. No inbound merge yet.
|
||||||
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
|
if !geohash.isEmpty {
|
||||||
bridgedEventIDs[postID] = eventID
|
publishToNostr(trimmed, geohash, cleanNickname)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -151,20 +140,14 @@ final class BoardManager: ObservableObject {
|
|||||||
signature: signature
|
signature: signature
|
||||||
)
|
)
|
||||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||||
|
|
||||||
// Merged delete: also retract the bridged Nostr copy when we still
|
|
||||||
// know its event id.
|
|
||||||
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
|
|
||||||
deleteFromNostr(eventID, post.geohash)
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
|
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||||
guard !relays.isEmpty else {
|
guard !relays.isEmpty else {
|
||||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||||
@@ -172,26 +155,11 @@ final class BoardManager: ObservableObject {
|
|||||||
content: content,
|
content: content,
|
||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: nickname,
|
nickname: nickname
|
||||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
|
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
return event.id
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
|
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else { return }
|
|
||||||
do {
|
|
||||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
|
||||||
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
|
|
||||||
NostrRelayManager.shared.sendEvent(deletion, to: relays)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,16 +78,6 @@ final class BoardStore {
|
|||||||
/// Live posts, published on the main thread for the board UI.
|
/// Live posts, published on the main thread for the board UI.
|
||||||
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
||||||
|
|
||||||
/// Fires on the main thread for each post newly accepted from the wire
|
|
||||||
/// (radio, sync, or local echo) — not for disk restores. Drives the
|
|
||||||
/// local new-pin chat alerts; duplicates never fire twice because the
|
|
||||||
/// store rejects them.
|
|
||||||
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
|
|
||||||
|
|
||||||
/// Fires on the main thread after a panic wipe so derived state (pending
|
|
||||||
/// alerts, unseen badges) is dropped along with the posts themselves.
|
|
||||||
let didWipe = PassthroughSubject<Void, Never>()
|
|
||||||
|
|
||||||
private var posts: [StoredPost] = []
|
private var posts: [StoredPost] = []
|
||||||
private var tombstones: [StoredTombstone] = []
|
private var tombstones: [StoredTombstone] = []
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
||||||
@@ -114,11 +104,6 @@ final class BoardStore {
|
|||||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||||
if result == .accepted {
|
if result == .accepted {
|
||||||
persistLocked()
|
persistLocked()
|
||||||
if case .post(let post) = wire {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.postArrivals.send(post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -164,9 +149,6 @@ final class BoardStore {
|
|||||||
}
|
}
|
||||||
publishSnapshotLocked()
|
publishSnapshotLocked()
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.didWipe.send()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals (call only on `queue`)
|
// MARK: - Internals (call only on `queue`)
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
//
|
|
||||||
// UnifiedNotices.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// One row in the unified notices sheet: a mesh board post or a Nostr
|
|
||||||
/// location note, normalized for display.
|
|
||||||
struct NoticeItem: Identifiable, Equatable {
|
|
||||||
enum Source: Equatable {
|
|
||||||
/// Signed board post carried by the mesh.
|
|
||||||
case board(BoardPostPacket)
|
|
||||||
/// Kind-1 location note seen on geo relays.
|
|
||||||
case nostr(LocationNotesManager.Note)
|
|
||||||
}
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
let author: String
|
|
||||||
let content: String
|
|
||||||
let createdAt: Date
|
|
||||||
let isUrgent: Bool
|
|
||||||
let source: Source
|
|
||||||
|
|
||||||
var isBoardPost: Bool {
|
|
||||||
if case .board = source { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
init(post: BoardPostPacket) {
|
|
||||||
id = post.postID.hexEncodedString()
|
|
||||||
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
|
||||||
content = post.content
|
|
||||||
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
|
||||||
isUrgent = post.isUrgent
|
|
||||||
source = .board(post)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(note: LocationNotesManager.Note) {
|
|
||||||
id = note.id
|
|
||||||
let display = note.displayName
|
|
||||||
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
|
||||||
.first.map(String.init) ?? display
|
|
||||||
content = note.content
|
|
||||||
createdAt = note.createdAt
|
|
||||||
isUrgent = false
|
|
||||||
source = .nostr(note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merges mesh board posts and Nostr location notes into one deduplicated
|
|
||||||
/// list for the notices sheet's geo tab.
|
|
||||||
enum UnifiedNotices {
|
|
||||||
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
|
|
||||||
/// post time, so the same notice arrives twice. The copies share content
|
|
||||||
/// and nickname but are signed by unlinkable keys; match them
|
|
||||||
/// heuristically by content + author within a time window.
|
|
||||||
static let bridgeDedupeWindow: TimeInterval = 15 * 60
|
|
||||||
|
|
||||||
/// Returns board posts and notes as one list, urgent posts first, then
|
|
||||||
/// newest first. Notes that look like bridged copies of a board post are
|
|
||||||
/// dropped — the board copy wins because it carries urgency and supports
|
|
||||||
/// merged deletion. The geohash must match exactly: the notes
|
|
||||||
/// subscription also surfaces neighboring cells, and a same-text note
|
|
||||||
/// from a neighbor is not the bridged copy.
|
|
||||||
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
|
|
||||||
var items = posts.map(NoticeItem.init(post:))
|
|
||||||
for note in notes {
|
|
||||||
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
|
|
||||||
let isBridgedCopy = posts.contains { post in
|
|
||||||
post.geohash == note.geohash
|
|
||||||
&& post.content == note.content
|
|
||||||
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
|
|
||||||
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
|
|
||||||
}
|
|
||||||
if !isBridgedCopy {
|
|
||||||
items.append(NoticeItem(note: note))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items.sorted {
|
|
||||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
|
||||||
return $0.createdAt > $1.createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,11 +38,7 @@ import Foundation
|
|||||||
/// original broadcast packet is the TTL relay's job, not ours.
|
/// original broadcast packet is the TTL relay's job, not ours.
|
||||||
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
||||||
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
||||||
/// repeat deposits and relay echoes are absorbed. An event this gateway
|
/// repeat deposits and relay echoes are absorbed.
|
||||||
/// itself uplinked (`publishedEventIDs`) is additionally never
|
|
||||||
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
|
|
||||||
/// when our own relay subscription redelivers it would double BLE airtime
|
|
||||||
/// (the device-confirmed self-echo bug).
|
|
||||||
/// 3. Uplink is only attempted for locally composed events at the send site
|
/// 3. Uplink is only attempted for locally composed events at the send site
|
||||||
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
||||||
/// carrier never re-enter the uplink path. This is a call-site convention;
|
/// carrier never re-enter the uplink path. This is a call-site convention;
|
||||||
@@ -156,7 +152,7 @@ final class GatewayService: ObservableObject {
|
|||||||
pendingDownlinks.removeAll()
|
pendingDownlinks.removeAll()
|
||||||
uplinkDepositTimes.removeAll()
|
uplinkDepositTimes.removeAll()
|
||||||
}
|
}
|
||||||
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
|
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
|
||||||
onEnabledChanged?(enabled)
|
onEnabledChanged?(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +163,7 @@ final class GatewayService: ObservableObject {
|
|||||||
/// for broadcasts (downlink rebroadcasts from a gateway).
|
/// for broadcasts (downlink rebroadcasts from a gateway).
|
||||||
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
||||||
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
||||||
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch carrier.direction {
|
switch carrier.direction {
|
||||||
@@ -190,7 +186,7 @@ final class GatewayService: ObservableObject {
|
|||||||
// age) — no crypto — so junk and stale replays are dropped before we
|
// age) — no crypto — so junk and stale replays are dropped before we
|
||||||
// ever pay for a MainActor Schnorr verify.
|
// ever pay for a MainActor Schnorr verify.
|
||||||
guard let event = structurallyValidEvent(from: carrier) else {
|
guard let event = structurallyValidEvent(from: carrier) else {
|
||||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||||
@@ -201,18 +197,19 @@ final class GatewayService: ObservableObject {
|
|||||||
guard !meshBroadcastEventIDs.contains(event.id),
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
!publishedEventIDs.contains(event.id),
|
!publishedEventIDs.contains(event.id),
|
||||||
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
|
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Consume the per-depositor rate token BEFORE the expensive verify so
|
// Consume the per-depositor rate token BEFORE the expensive verify so
|
||||||
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
||||||
// not by main-actor Schnorr verifications.
|
// not by main-actor Schnorr verifications.
|
||||||
guard allowUplinkDeposit(from: depositor) else {
|
guard allowUplinkDeposit(from: depositor) else {
|
||||||
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only now pay for cryptographic verification; receivers verify again.
|
// Only now pay for cryptographic verification; receivers verify again.
|
||||||
guard event.isValidSignature() else {
|
guard event.isValidSignature() else {
|
||||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +219,9 @@ final class GatewayService: ObservableObject {
|
|||||||
accepted = true
|
accepted = true
|
||||||
} else {
|
} else {
|
||||||
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
||||||
|
if accepted {
|
||||||
|
SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render on our own timeline what we actually accepted for
|
// Only render on our own timeline what we actually accepted for
|
||||||
@@ -247,7 +247,7 @@ final class GatewayService: ObservableObject {
|
|||||||
private func publish(_ event: NostrEvent, geohash: String) {
|
private func publish(_ event: NostrEvent, geohash: String) {
|
||||||
publishedEventIDs.insert(event.id)
|
publishedEventIDs.insert(event.id)
|
||||||
publishToRelays?(event, geohash)
|
publishToRelays?(event, geohash)
|
||||||
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
|
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true when the item was actually stored for later publish.
|
/// Returns true when the item was actually stored for later publish.
|
||||||
@@ -255,7 +255,7 @@ final class GatewayService: ObservableObject {
|
|||||||
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||||
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||||
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
||||||
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session)
|
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||||
@@ -298,26 +298,26 @@ final class GatewayService: ObservableObject {
|
|||||||
// event's own `#g` tag to match the carrier geohash.
|
// event's own `#g` tag to match the carrier geohash.
|
||||||
guard isFresh(event),
|
guard isFresh(event),
|
||||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
||||||
|
SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||||
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
|
// mesh. Loop rule 2: rebroadcast each relay event at most once — but
|
||||||
// gateway itself uplinked (`publishedEventIDs`) — it originated on this
|
// mark only AFTER it is actually sent (in `drainPendingDownlinks`), so
|
||||||
// very mesh, so our own relay subscription echoing it back must not
|
// an event dropped by the queue overflow stays retryable on relay
|
||||||
// double the BLE airtime by pushing it out again. Loop rule 2
|
|
||||||
// (downlink): rebroadcast each genuine inbound relay event at most once
|
|
||||||
// — but mark only AFTER it is actually sent (in `drainPendingDownlinks`),
|
|
||||||
// so an event dropped by the queue overflow stays retryable on relay
|
|
||||||
// redelivery. Guard against a redelivery re-queueing an event that is
|
// redelivery. Guard against a redelivery re-queueing an event that is
|
||||||
// still waiting to be sent.
|
// still waiting to be sent.
|
||||||
guard !meshBroadcastEventIDs.contains(event.id),
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
!publishedEventIDs.contains(event.id),
|
|
||||||
!rebroadcastEventIDs.contains(event.id),
|
!rebroadcastEventIDs.contains(event.id),
|
||||||
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
|
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Verify before spending BLE airtime; receivers verify again.
|
// Verify before spending BLE airtime; receivers verify again.
|
||||||
guard event.isValidSignature() else { return }
|
guard event.isValidSignature() else {
|
||||||
|
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
pendingDownlinks.append((event, geohash))
|
pendingDownlinks.append((event, geohash))
|
||||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||||
@@ -325,6 +325,7 @@ final class GatewayService: ObservableObject {
|
|||||||
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
||||||
// relay redelivery can still carry it.
|
// relay redelivery can still carry it.
|
||||||
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||||
|
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
|
||||||
}
|
}
|
||||||
drainPendingDownlinks()
|
drainPendingDownlinks()
|
||||||
}
|
}
|
||||||
@@ -341,6 +342,7 @@ final class GatewayService: ObservableObject {
|
|||||||
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||||
let payload = carrier.encode() else { continue }
|
let payload = carrier.encode() else { continue }
|
||||||
broadcastToMesh?(payload)
|
broadcastToMesh?(payload)
|
||||||
|
SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway)
|
||||||
// Mark-after-send: only now is the relay event definitively
|
// Mark-after-send: only now is the relay event definitively
|
||||||
// rebroadcast (loop rule 2).
|
// rebroadcast (loop rule 2).
|
||||||
rebroadcastEventIDs.insert(event.id)
|
rebroadcastEventIDs.insert(event.id)
|
||||||
@@ -360,9 +362,11 @@ final class GatewayService: ObservableObject {
|
|||||||
let oldest = downlinkSendTimes.min() ?? now()
|
let oldest = downlinkSendTimes.min() ?? now()
|
||||||
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||||
downlinkDrainScheduled = true
|
downlinkDrainScheduled = true
|
||||||
|
SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway)
|
||||||
let fire: @MainActor () -> Void = { [weak self] in
|
let fire: @MainActor () -> Void = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.downlinkDrainScheduled = false
|
self.downlinkDrainScheduled = false
|
||||||
|
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
|
||||||
self.drainPendingDownlinks()
|
self.drainPendingDownlinks()
|
||||||
}
|
}
|
||||||
if let scheduleDrainTimer {
|
if let scheduleDrainTimer {
|
||||||
@@ -417,7 +421,7 @@ final class GatewayService: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
||||||
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session)
|
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,50 +15,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
|
||||||
// device locked (identity-cache saves failed with -25308 throughout
|
|
||||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
|
||||||
// restoration must be able to read the noise keys before the user
|
|
||||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
|
||||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
|
||||||
|
|
||||||
init() {
|
|
||||||
#if os(iOS)
|
|
||||||
migrateAccessibilityIfNeeded()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
|
||||||
/// the right class on their own (saves are delete-then-add), but the
|
|
||||||
/// long-lived identity keys are written once and would otherwise stay
|
|
||||||
/// unreadable while the device is locked.
|
|
||||||
private func migrateAccessibilityIfNeeded() {
|
|
||||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
|
||||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
|
||||||
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service
|
|
||||||
]
|
|
||||||
let update: [String: Any] = [
|
|
||||||
kSecAttrAccessible as String: Self.itemAccessibility
|
|
||||||
]
|
|
||||||
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess, errSecItemNotFound:
|
|
||||||
// Nothing to migrate on a fresh install; both are terminal.
|
|
||||||
UserDefaults.standard.set(true, forKey: flag)
|
|
||||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
|
||||||
// leave the flag unset so the next launch retries.
|
|
||||||
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
@@ -105,7 +62,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data,
|
kSecValueData as String: data,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccessible as String: Self.itemAccessibility,
|
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
kSecAttrLabel as String: "bitchat-\(key)"
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -270,7 +227,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data,
|
kSecValueData as String: data,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccessible as String: Self.itemAccessibility,
|
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
kSecAttrLabel as String: "bitchat-\(key)"
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let content: String
|
let content: String
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let nickname: String?
|
let nickname: String?
|
||||||
/// The matched `g` tag: the cell the note was posted to, which can be
|
|
||||||
/// a neighbor of the subscribed geohash.
|
|
||||||
let geohash: String
|
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
let suffix = String(pubkey.suffix(4))
|
let suffix = String(pubkey.suffix(4))
|
||||||
@@ -85,8 +82,6 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
@Published private(set) var state: State = .loading
|
@Published private(set) var state: State = .loading
|
||||||
@Published private(set) var errorMessage: String?
|
@Published private(set) var errorMessage: String?
|
||||||
/// Public key of our per-geohash Nostr identity; identifies our own notes.
|
|
||||||
private var ownPubkey: String?
|
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
private var directoryUpdateCancellable: AnyCancellable?
|
private var directoryUpdateCancellable: AnyCancellable?
|
||||||
@@ -109,10 +104,10 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let norm = geohash.lowercased()
|
let norm = geohash.lowercased()
|
||||||
self.geohash = norm
|
self.geohash = norm
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
if !Geohash.isValidGeohash(norm) {
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
if !Geohash.isValidBuildingGeohash(norm) {
|
||||||
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
}
|
}
|
||||||
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
|
||||||
subscribe()
|
subscribe()
|
||||||
// The relay directory may load after init (remote fetch over Tor);
|
// The relay directory may load after init (remote fetch over Tor);
|
||||||
// retry automatically instead of staying stuck on "no relays".
|
// retry automatically instead of staying stuck on "no relays".
|
||||||
@@ -128,8 +123,9 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
guard Geohash.isValidGeohash(norm) else {
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
@@ -141,7 +137,6 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
@@ -198,14 +193,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag - accept any of our 9 geohashes
|
// Ensure matching tag - accept any of our 9 geohashes
|
||||||
guard let matchedGeohash = event.tags.first(where: { tag in
|
guard event.tags.contains(where: { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||||
})?[1].lowercased() else { return }
|
}) 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
|
||||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
self.enforceMemoryCap()
|
self.enforceMemoryCap()
|
||||||
@@ -244,8 +239,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
pubkey: id.publicKeyHex,
|
pubkey: id.publicKeyHex,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
nickname: nickname,
|
nickname: nickname
|
||||||
geohash: geohash
|
|
||||||
)
|
)
|
||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
@@ -258,36 +252,6 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the note was published by this device's identity for the
|
|
||||||
/// current geohash (and can therefore be deleted with NIP-09).
|
|
||||||
func isOwnNote(_ note: Note) -> Bool {
|
|
||||||
guard let ownPubkey else { return false }
|
|
||||||
return note.pubkey == ownPubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Requests NIP-09 deletion of one of our own notes and removes it locally.
|
|
||||||
@discardableResult
|
|
||||||
func delete(note: Note) -> Bool {
|
|
||||||
guard isOwnNote(note) else { return false }
|
|
||||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else {
|
|
||||||
state = .noRelays
|
|
||||||
errorMessage = Strings.noRelays
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let identity = try dependencies.deriveIdentity(geohash)
|
|
||||||
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity)
|
|
||||||
dependencies.sendEvent(deletion, relays)
|
|
||||||
// Keep the id in noteIDs so a relay replay can't resurrect it.
|
|
||||||
notes.removeAll { $0.id == note.id }
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enforces defensive memory cap on notes array (keeps newest).
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||||
private func enforceMemoryCap() {
|
private func enforceMemoryCap() {
|
||||||
if notes.count > maxNotesInMemory {
|
if notes.count > maxNotesInMemory {
|
||||||
|
|||||||
@@ -172,37 +172,17 @@ final class MessageDeduplicationService {
|
|||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||||
|
|
||||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
|
||||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
|
||||||
/// relays redeliver the same events on every launch; without this record
|
|
||||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
|
||||||
/// that don't opt in) keeps the cache purely in-memory.
|
|
||||||
private let nostrEventStore: NostrProcessedEventStore?
|
|
||||||
private let nostrEventCapacity: Int
|
|
||||||
private var persistScheduled = false
|
|
||||||
private var pendingPersistIDs: [String] = []
|
|
||||||
|
|
||||||
/// Creates a new deduplication service with specified capacities.
|
/// Creates a new deduplication service with specified capacities.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - contentCapacity: Max entries for content cache
|
/// - contentCapacity: Max entries for content cache
|
||||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
/// - nostrEventCapacity: Max entries for Nostr event cache
|
||||||
/// - nostrEventStore: Optional disk store preloading and persisting
|
|
||||||
/// processed Nostr event IDs across launches
|
|
||||||
init(
|
init(
|
||||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
contentCapacity: Int = TransportConfig.contentLRUCap,
|
||||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap,
|
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
||||||
nostrEventStore: NostrProcessedEventStore? = nil
|
|
||||||
) {
|
) {
|
||||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
||||||
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
||||||
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
||||||
self.nostrEventStore = nostrEventStore
|
|
||||||
self.nostrEventCapacity = nostrEventCapacity
|
|
||||||
if let nostrEventStore {
|
|
||||||
for eventID in nostrEventStore.load() {
|
|
||||||
nostrEventCache.record(eventID, value: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Content Deduplication
|
// MARK: - Content Deduplication
|
||||||
@@ -259,26 +239,6 @@ final class MessageDeduplicationService {
|
|||||||
/// - Parameter eventId: The event ID
|
/// - Parameter eventId: The event ID
|
||||||
func recordNostrEvent(_ eventId: String) {
|
func recordNostrEvent(_ eventId: String) {
|
||||||
nostrEventCache.record(eventId, value: true)
|
nostrEventCache.record(eventId, value: true)
|
||||||
if nostrEventStore != nil {
|
|
||||||
pendingPersistIDs.append(eventId)
|
|
||||||
schedulePersistIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Debounced persistence: bursts of inbound events (reconnect redelivery)
|
|
||||||
/// collapse into one append. Append-merge rather than snapshot, so a
|
|
||||||
/// transient in-memory clear between flushes can't shrink the disk record.
|
|
||||||
private func schedulePersistIfNeeded() {
|
|
||||||
guard let nostrEventStore, !persistScheduled else { return }
|
|
||||||
persistScheduled = true
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
|
||||||
guard let self else { return }
|
|
||||||
self.persistScheduled = false
|
|
||||||
let newIDs = self.pendingPersistIDs
|
|
||||||
self.pendingPersistIDs.removeAll()
|
|
||||||
nostrEventStore.append(newIDs, cap: self.nostrEventCapacity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Nostr ACK Deduplication
|
// MARK: - Nostr ACK Deduplication
|
||||||
@@ -303,20 +263,14 @@ final class MessageDeduplicationService {
|
|||||||
|
|
||||||
// MARK: - Clear
|
// MARK: - Clear
|
||||||
|
|
||||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
/// Clears all caches
|
||||||
/// gift-wrap record goes with everything else.
|
|
||||||
func clearAll() {
|
func clearAll() {
|
||||||
contentCache.clear()
|
contentCache.clear()
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
nostrAckCache.clear()
|
nostrAckCache.clear()
|
||||||
pendingPersistIDs.removeAll()
|
|
||||||
nostrEventStore?.wipe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
/// Clears only the Nostr caches (events and ACKs)
|
||||||
/// every geohash channel switch, so the disk record deliberately
|
|
||||||
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
|
||||||
/// each time the user changes channels (flagged by Codex on #1398).
|
|
||||||
func clearNostrCaches() {
|
func clearNostrCaches() {
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
nostrAckCache.clear()
|
nostrAckCache.clear()
|
||||||
|
|||||||
@@ -25,20 +25,14 @@ extension NostrRelayManager: NetworkActivationRelayControlling {}
|
|||||||
extension TorURLSession: NetworkActivationProxyControlling {}
|
extension TorURLSession: NetworkActivationProxyControlling {}
|
||||||
|
|
||||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
/// Policy: permit start when (location permissions are authorized OR there
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
/// exists at least one mutual favorite) AND the device has a usable network
|
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||||
/// path. When there is provably no network at all we do not bootstrap Tor or
|
|
||||||
/// spin relay reconnects — that only wastes battery on a mesh-only/offline
|
|
||||||
/// device. BLE mesh is entirely independent of this gate.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NetworkActivationService: ObservableObject {
|
final class NetworkActivationService: ObservableObject {
|
||||||
static let shared = NetworkActivationService()
|
static let shared = NetworkActivationService()
|
||||||
|
|
||||||
@Published private(set) var activationAllowed: Bool = false
|
@Published private(set) var activationAllowed: Bool = false
|
||||||
@Published private(set) var userTorEnabled: Bool = true
|
@Published private(set) var userTorEnabled: Bool = true
|
||||||
/// Coarse, debounced network reachability. `false` only when the OS reports
|
|
||||||
/// no usable interface at all. Surfaced for UI ("offline" vs "connecting").
|
|
||||||
@Published private(set) var isNetworkReachable: Bool = true
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var started = false
|
private var started = false
|
||||||
@@ -49,7 +43,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||||
private let mutualFavoritesProvider: () -> Set<Data>
|
private let mutualFavoritesProvider: () -> Set<Data>
|
||||||
private let reachabilityMonitor: NetworkReachabilityMonitoring
|
|
||||||
private let torController: NetworkActivationTorControlling
|
private let torController: NetworkActivationTorControlling
|
||||||
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
||||||
// (via its live dependencies), so capturing NostrRelayManager.shared here would
|
// (via its live dependencies), so capturing NostrRelayManager.shared here would
|
||||||
@@ -65,7 +58,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
||||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||||
reachabilityMonitor = NWPathReachabilityMonitor()
|
|
||||||
torController = TorManager.shared
|
torController = TorManager.shared
|
||||||
relayControllerProvider = { NostrRelayManager.shared }
|
relayControllerProvider = { NostrRelayManager.shared }
|
||||||
proxyController = TorURLSession.shared
|
proxyController = TorURLSession.shared
|
||||||
@@ -78,7 +70,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||||
reachabilityMonitor: NetworkReachabilityMonitoring,
|
|
||||||
torController: NetworkActivationTorControlling,
|
torController: NetworkActivationTorControlling,
|
||||||
relayController: NetworkActivationRelayControlling,
|
relayController: NetworkActivationRelayControlling,
|
||||||
proxyController: NetworkActivationProxyControlling,
|
proxyController: NetworkActivationProxyControlling,
|
||||||
@@ -89,7 +80,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||||
self.permissionProvider = permissionProvider
|
self.permissionProvider = permissionProvider
|
||||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||||
self.reachabilityMonitor = reachabilityMonitor
|
|
||||||
self.torController = torController
|
self.torController = torController
|
||||||
self.relayControllerProvider = { relayController }
|
self.relayControllerProvider = { relayController }
|
||||||
self.proxyController = proxyController
|
self.proxyController = proxyController
|
||||||
@@ -106,12 +96,8 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
userTorEnabled = true
|
userTorEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin (idempotent) reachability monitoring and seed initial state.
|
|
||||||
reachabilityMonitor.start()
|
|
||||||
isNetworkReachable = reachabilityMonitor.isReachable
|
|
||||||
|
|
||||||
// Initial compute
|
// Initial compute
|
||||||
let allowed = effectiveAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
activationAllowed = allowed
|
activationAllowed = allowed
|
||||||
torAutoStartDesired = allowed && userTorEnabled
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||||
@@ -137,21 +123,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// React to network reachability changes (debounced, unsatisfied-only).
|
|
||||||
reachabilityMonitor.reachabilityPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] reachable in
|
|
||||||
guard let self else { return }
|
|
||||||
guard reachable != self.isNetworkReachable else { return }
|
|
||||||
self.isNetworkReachable = reachable
|
|
||||||
SecureLogger.info(
|
|
||||||
"NetworkActivationService: isNetworkReachable -> \(reachable)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
self.reevaluate()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUserTorEnabled(_ enabled: Bool) {
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
@@ -167,7 +138,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reevaluate() {
|
private func reevaluate() {
|
||||||
let allowed = effectiveAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
let torDesired = allowed && userTorEnabled
|
let torDesired = allowed && userTorEnabled
|
||||||
let statusChanged = allowed != activationAllowed
|
let statusChanged = allowed != activationAllowed
|
||||||
let torChanged = torDesired != torAutoStartDesired
|
let torChanged = torDesired != torAutoStartDesired
|
||||||
@@ -192,20 +163,12 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Base policy: who is allowed to use the network at all (permission or a
|
|
||||||
/// mutual favorite), ignoring current link state.
|
|
||||||
private func basePolicyAllowed() -> Bool {
|
private func basePolicyAllowed() -> Bool {
|
||||||
let permOK = permissionProvider() == .authorized
|
let permOK = permissionProvider() == .authorized
|
||||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||||
return permOK || hasMutual
|
return permOK || hasMutual
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Effective gate: base policy AND a usable network path. When there is
|
|
||||||
/// provably no network, Tor bootstrap and relay reconnects are suppressed.
|
|
||||||
private func effectiveAllowed() -> Bool {
|
|
||||||
basePolicyAllowed() && reachabilityMonitor.isReachable
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyTorState(torDesired: Bool) {
|
private func applyTorState(torDesired: Bool) {
|
||||||
proxyController.setProxyMode(useTor: torDesired)
|
proxyController.setProxyMode(useTor: torDesired)
|
||||||
if torDesired {
|
if torDesired {
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
#if canImport(Network)
|
|
||||||
import Network
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Coarse, conservative network-reachability signal used to gate Tor bootstrap
|
|
||||||
/// and Nostr relay connections.
|
|
||||||
///
|
|
||||||
/// Policy (deliberately conservative):
|
|
||||||
/// - Reports `false` only when the OS says there is *no* usable interface at
|
|
||||||
/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays
|
|
||||||
/// `true` because Tor tolerates intermittent connectivity, and tearing down
|
|
||||||
/// on the first hiccup would cost more battery/latency than it saves.
|
|
||||||
/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping
|
|
||||||
/// does not thrash Tor/relay startup.
|
|
||||||
/// - Starts optimistic (`true`) so nothing is ever suppressed before the first
|
|
||||||
/// path evaluation arrives.
|
|
||||||
///
|
|
||||||
/// BLE mesh must never consult this monitor — the mesh works fully offline.
|
|
||||||
@MainActor
|
|
||||||
protocol NetworkReachabilityMonitoring: AnyObject {
|
|
||||||
/// Current debounced coarse reachability.
|
|
||||||
var isReachable: Bool { get }
|
|
||||||
/// Emits the debounced reachability whenever it changes (main-actor).
|
|
||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
|
||||||
/// Begin monitoring. Idempotent.
|
|
||||||
func start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
|
||||||
/// unit-tested without the Network framework or real timers.
|
|
||||||
///
|
|
||||||
/// A candidate state only becomes the committed state once it has been stable
|
|
||||||
/// (uninterrupted) for `interval`. Any observation matching the committed state
|
|
||||||
/// cancels a pending opposite change, which is what makes flapping a no-op.
|
|
||||||
struct ReachabilityDebounce {
|
|
||||||
let interval: TimeInterval
|
|
||||||
private(set) var committed: Bool
|
|
||||||
private var pending: (value: Bool, since: Date)?
|
|
||||||
|
|
||||||
init(interval: TimeInterval, initial: Bool) {
|
|
||||||
self.interval = interval
|
|
||||||
self.committed = initial
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a change is currently waiting out the debounce window.
|
|
||||||
var hasPendingChange: Bool { pending != nil }
|
|
||||||
|
|
||||||
/// Time left before the pending change may commit, or `nil` when nothing
|
|
||||||
/// is pending. Lets callers schedule a flush at the true deadline instead
|
|
||||||
/// of a full interval from "now" (duplicate observations must not push
|
|
||||||
/// the deadline out).
|
|
||||||
func pendingRemaining(at now: Date) -> TimeInterval? {
|
|
||||||
guard let pending else { return nil }
|
|
||||||
return max(0, interval - now.timeIntervalSince(pending.since))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a raw observation. Returns the new committed value if it changed,
|
|
||||||
/// otherwise `nil`.
|
|
||||||
mutating func observe(reachable: Bool, at now: Date) -> Bool? {
|
|
||||||
if reachable == committed {
|
|
||||||
// Already in this state — cancel any pending opposite change.
|
|
||||||
pending = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Differs from committed: (re)arm the pending change, preserving the
|
|
||||||
// timestamp if we're already waiting on this same target value.
|
|
||||||
if pending?.value != reachable {
|
|
||||||
pending = (reachable, now)
|
|
||||||
}
|
|
||||||
return commitIfAged(at: now)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called from a timer to commit a pending change once it has aged past
|
|
||||||
/// `interval`. Returns the new committed value if it changed, else `nil`.
|
|
||||||
mutating func flush(at now: Date) -> Bool? {
|
|
||||||
commitIfAged(at: now)
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func commitIfAged(at now: Date) -> Bool? {
|
|
||||||
guard let pending else { return nil }
|
|
||||||
guard now.timeIntervalSince(pending.since) >= interval else { return nil }
|
|
||||||
committed = pending.value
|
|
||||||
self.pending = nil
|
|
||||||
return committed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Always-reachable stub. Used as the default in tests and as the fallback on
|
|
||||||
/// platforms without the Network framework, so reachability never suppresses
|
|
||||||
/// startup by itself.
|
|
||||||
@MainActor
|
|
||||||
final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
|
||||||
var isReachable: Bool { true }
|
|
||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
|
||||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func start() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
|
||||||
/// background path callback hops here before touching the debounce.
|
|
||||||
@MainActor
|
|
||||||
final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
|
||||||
private let subject: CurrentValueSubject<Bool, Never>
|
|
||||||
private var debounce: ReachabilityDebounce
|
|
||||||
private var flushWorkItem: DispatchWorkItem?
|
|
||||||
private var started = false
|
|
||||||
private let now: () -> Date
|
|
||||||
|
|
||||||
#if canImport(Network)
|
|
||||||
private var monitor: NWPathMonitor?
|
|
||||||
private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) {
|
|
||||||
self.now = now
|
|
||||||
self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true)
|
|
||||||
self.subject = CurrentValueSubject(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isReachable: Bool { subject.value }
|
|
||||||
|
|
||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
|
||||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard !started else { return }
|
|
||||||
started = true
|
|
||||||
#if canImport(Network)
|
|
||||||
let monitor = NWPathMonitor()
|
|
||||||
self.monitor = monitor
|
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
|
||||||
// Conservative: only "no interface at all" counts as unreachable.
|
|
||||||
let reachable = path.status != .unsatisfied
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.ingest(reachable: reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
monitor.start(queue: monitorQueue)
|
|
||||||
#else
|
|
||||||
// No Network framework: never suppress startup.
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed an observation into the debounce and publish committed changes.
|
|
||||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
|
||||||
func ingest(reachable: Bool) {
|
|
||||||
flushWorkItem?.cancel()
|
|
||||||
flushWorkItem = nil
|
|
||||||
if let committed = debounce.observe(reachable: reachable, at: now()) {
|
|
||||||
publish(committed)
|
|
||||||
} else if debounce.hasPendingChange {
|
|
||||||
scheduleFlush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleFlush() {
|
|
||||||
let work = DispatchWorkItem { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
if let committed = self.debounce.flush(at: self.now()) {
|
|
||||||
self.publish(committed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushWorkItem = work
|
|
||||||
// Fire at the pending change's real deadline (pending.since + interval):
|
|
||||||
// duplicate path updates re-enter here and must not restart the window.
|
|
||||||
let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func publish(_ reachable: Bool) {
|
|
||||||
SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session)
|
|
||||||
subject.send(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -463,6 +463,7 @@ final class NoiseEncryptionService {
|
|||||||
/// so the caller can re-gossip the shrunken bundle only when it changed.
|
/// so the caller can re-gossip the shrunken bundle only when it changed.
|
||||||
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
|
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
|
||||||
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
|
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
|
||||||
|
SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session)
|
||||||
throw NoiseEncryptionError.unknownPrekey
|
throw NoiseEncryptionError.unknownPrekey
|
||||||
}
|
}
|
||||||
let handshake = NoiseHandshakeState(
|
let handshake = NoiseHandshakeState(
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
//
|
|
||||||
// NostrProcessedEventStore.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
|
||||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
|
||||||
/// and relays redeliver the same events on every launch — without a
|
|
||||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
|
||||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
|
||||||
///
|
|
||||||
/// Contents are event IDs already visible to every relay, so
|
|
||||||
/// until-first-unlock file protection is the right at-rest posture — the
|
|
||||||
/// file must also load during a locked-background restoration relaunch.
|
|
||||||
/// Wiped on panic via the dedup service's clear paths.
|
|
||||||
final class NostrProcessedEventStore {
|
|
||||||
private let fileURL: URL?
|
|
||||||
// All file access is serialized here: appends are read-modify-write, and
|
|
||||||
// overlapping debounced flushes would otherwise race and drop IDs.
|
|
||||||
private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility)
|
|
||||||
|
|
||||||
init(fileURL: URL? = nil) {
|
|
||||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processed event IDs, oldest first (insertion order).
|
|
||||||
func load() -> [String] {
|
|
||||||
ioQueue.sync { loadLocked() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge new IDs onto the persisted record, oldest-first, trimming from
|
|
||||||
/// the front past `cap`. Append-merge (not snapshot-overwrite) so the
|
|
||||||
/// in-memory cache being cleared transiently (channel switches) can
|
|
||||||
/// never shrink the on-disk record.
|
|
||||||
func append(_ newIDs: [String], cap: Int) {
|
|
||||||
guard !newIDs.isEmpty else { return }
|
|
||||||
ioQueue.async { [self] in
|
|
||||||
var merged = loadLocked()
|
|
||||||
var known = Set(merged)
|
|
||||||
for id in newIDs where !known.contains(id) {
|
|
||||||
merged.append(id)
|
|
||||||
known.insert(id)
|
|
||||||
}
|
|
||||||
if merged.count > cap {
|
|
||||||
merged.removeFirst(merged.count - cap)
|
|
||||||
}
|
|
||||||
saveLocked(merged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wipe() {
|
|
||||||
ioQueue.async { [self] in
|
|
||||||
guard let fileURL else { return }
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadLocked() -> [String] {
|
|
||||||
guard let fileURL,
|
|
||||||
let data = try? Data(contentsOf: fileURL),
|
|
||||||
let ids = try? JSONDecoder().decode([String].self, from: data) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveLocked(_ eventIDs: [String]) {
|
|
||||||
guard let fileURL else { return }
|
|
||||||
guard !eventIDs.isEmpty else {
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: fileURL.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true
|
|
||||||
)
|
|
||||||
let data = try JSONEncoder().encode(eventIDs)
|
|
||||||
var options: Data.WritingOptions = [.atomic]
|
|
||||||
#if os(iOS)
|
|
||||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
|
||||||
#endif
|
|
||||||
try data.write(to: fileURL, options: options)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func defaultFileURL() -> URL? {
|
|
||||||
guard let base = try? FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
) else { return nil }
|
|
||||||
return base
|
|
||||||
.appendingPathComponent("nostr", isDirectory: true)
|
|
||||||
.appendingPathComponent("processed-events.json")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,36 +19,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
/// doesn't count: DM sends target the default relay set and would
|
/// doesn't count: DM sends target the default relay set and would
|
||||||
/// still queue.
|
/// still queue.
|
||||||
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
||||||
/// Paces outbound acks. Defaults to an isolated pacer so tests don't
|
|
||||||
/// serialize behind each other; `live` passes the process-wide one.
|
|
||||||
let ackPacer: AckPacer
|
|
||||||
|
|
||||||
init(
|
|
||||||
notificationCenter: NotificationCenter,
|
|
||||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
|
||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
|
||||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
|
||||||
ackPacer: AckPacer? = nil
|
|
||||||
) {
|
|
||||||
self.notificationCenter = notificationCenter
|
|
||||||
self.loadFavorites = loadFavorites
|
|
||||||
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
|
||||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
|
||||||
self.currentIdentity = currentIdentity
|
|
||||||
self.registerPendingGiftWrap = registerPendingGiftWrap
|
|
||||||
self.sendEvent = sendEvent
|
|
||||||
self.scheduleAfter = scheduleAfter
|
|
||||||
self.relayConnectivity = relayConnectivity
|
|
||||||
// Default pacer drives its throttle through the same injected
|
|
||||||
// scheduler, so tests that step scheduleAfter manually keep
|
|
||||||
// control of the ack cadence.
|
|
||||||
self.ackPacer = ackPacer ?? AckPacer(scheduleAfter: scheduleAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||||
@@ -63,8 +33,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
},
|
},
|
||||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||||
ackPacer: NostrTransport.sharedAckPacer
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,64 +41,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID = PeerID(str: "")
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
// Throttle outbound acks — READ receipts and DELIVERED acks, direct and
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
// geohash — to avoid relay rate limits. Reconnect redelivery produces a
|
private struct QueuedRead {
|
||||||
// burst of acks at once: 8 DELIVERED in under a second tripped damus's
|
let receipt: ReadReceipt
|
||||||
// "noting too much" during July 2026 device testing.
|
let peerID: PeerID
|
||||||
private enum QueuedAck {
|
|
||||||
case readDirect(ReadReceipt, PeerID)
|
|
||||||
case deliveredDirect(messageID: String, peerID: PeerID)
|
|
||||||
case deliveredGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
|
|
||||||
case readGeohash(messageID: String, recipientHex: String, identity: NostrIdentity)
|
|
||||||
}
|
}
|
||||||
|
private var readQueue: [QueuedRead] = []
|
||||||
/// Ack pacing shared across transport instances. Geohash acks are sent
|
private var isSendingReadAcks = false
|
||||||
/// through short-lived transports created per ack
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
/// (`makeGeohashNostrTransport()`), so a per-instance queue would only
|
|
||||||
/// ever hold one item and never pace a burst (flagged by Codex on
|
|
||||||
/// #1398). Production wires `sharedAckPacer` via `Dependencies.live`;
|
|
||||||
/// tests get an isolated instance per `Dependencies` by default.
|
|
||||||
/// @unchecked Sendable: all mutable state (`pending`, `isSending`) is
|
|
||||||
/// confined to the serial `queue`; the class is only touched via
|
|
||||||
/// `enqueue` and the scheduler callback, both of which hop onto it.
|
|
||||||
final class AckPacer: @unchecked Sendable {
|
|
||||||
typealias Scheduler = @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.nostr-ack-pacer")
|
|
||||||
private var pending: [() -> Void] = []
|
|
||||||
private var isSending = false
|
|
||||||
private let interval: TimeInterval = TransportConfig.nostrReadAckInterval
|
|
||||||
private let scheduleAfter: Scheduler
|
|
||||||
|
|
||||||
init(scheduleAfter: @escaping Scheduler = { delay, action in
|
|
||||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action)
|
|
||||||
}) {
|
|
||||||
self.scheduleAfter = scheduleAfter
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueue(_ send: @escaping () -> Void) {
|
|
||||||
queue.async {
|
|
||||||
self.pending.append(send)
|
|
||||||
self.processNext()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Must be called on `queue`.
|
|
||||||
private func processNext() {
|
|
||||||
guard !isSending, !pending.isEmpty else { return }
|
|
||||||
isSending = true
|
|
||||||
let send = pending.removeFirst()
|
|
||||||
send()
|
|
||||||
scheduleAfter(interval) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.queue.async {
|
|
||||||
self.isSending = false
|
|
||||||
self.processNext()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static let sharedAckPacer = AckPacer()
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
private let dependencies: Dependencies
|
private let dependencies: Dependencies
|
||||||
@@ -268,14 +187,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
enqueueAck(.readDirect(receipt, peerID))
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
}
|
// Use barrier to synchronize access to readQueue
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
/// Enqueue an ack for paced sending. Captures self strongly on purpose:
|
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
/// geohash acks ride throwaway transport instances that must stay alive
|
self.processReadQueueIfNeeded()
|
||||||
/// until their ack leaves the queue.
|
}
|
||||||
private func enqueueAck(_ ack: QueuedAck) {
|
|
||||||
dependencies.ackPacer.enqueue { self.sendAckItem(ack) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
@@ -295,7 +212,17 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID))
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,11 +232,19 @@ extension NostrTransport {
|
|||||||
|
|
||||||
// MARK: Geohash ACK helpers
|
// MARK: Geohash ACK helpers
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Geohash DMs (per-geohash identity)
|
// MARK: Geohash DMs (per-geohash identity)
|
||||||
@@ -355,42 +290,36 @@ extension NostrTransport {
|
|||||||
dependencies.sendEvent(event)
|
dependencies.sendEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Must be called within a barrier on `queue`
|
||||||
|
private func processReadQueueIfNeeded() {
|
||||||
|
guard !isSendingReadAcks else { return }
|
||||||
|
guard !readQueue.isEmpty else { return }
|
||||||
|
isSendingReadAcks = true
|
||||||
|
let item = readQueue.removeFirst()
|
||||||
|
sendReadAckItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends a single ack item (invoked by the pacer, one per interval)
|
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||||
private func sendAckItem(_ item: QueuedAck) {
|
private func sendReadAckItem(_ item: QueuedRead) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch item {
|
defer { scheduleNextReadAck() }
|
||||||
case .readDirect(let receipt, let peerID):
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
return
|
||||||
return
|
}
|
||||||
}
|
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .deliveredDirect(let messageID, let peerID):
|
private func scheduleNextReadAck() {
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
self?.queue.async(flags: .barrier) { [weak self] in
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
self?.isSendingReadAcks = false
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
self?.processReadQueueIfNeeded()
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
||||||
|
|
||||||
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
||||||
|
|
||||||
case .readGeohash(let messageID, let recipientHex, let identity):
|
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,10 +167,6 @@ protocol Transport: AnyObject {
|
|||||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
||||||
func broadcastGroupMessage(_ envelope: Data)
|
func broadcastGroupMessage(_ envelope: Data)
|
||||||
|
|
||||||
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
|
||||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
|
||||||
func sendBoardPayload(_ payload: Data)
|
|
||||||
|
|
||||||
// Mesh diagnostics (optional for transports). Defaults are inert so
|
// Mesh diagnostics (optional for transports). Defaults are inert so
|
||||||
// queue-backed transports (e.g. NostrTransport) stay untouched.
|
// queue-backed transports (e.g. NostrTransport) stay untouched.
|
||||||
/// Sends a directed ping probe; the completion fires exactly once on the
|
/// Sends a directed ping probe; the completion fires exactly once on the
|
||||||
@@ -182,6 +178,10 @@ protocol Transport: AnyObject {
|
|||||||
/// Current mesh graph for the topology map; nil when unsupported.
|
/// Current mesh graph for the topology map; nil when unsupported.
|
||||||
func currentMeshTopology() -> MeshTopologySnapshot?
|
func currentMeshTopology() -> MeshTopologySnapshot?
|
||||||
|
|
||||||
|
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||||
|
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||||
|
func sendBoardPayload(_ payload: Data)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
@@ -223,14 +223,13 @@ extension Transport {
|
|||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
|
|
||||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
|
||||||
func broadcastGroupMessage(_ envelope: Data) {}
|
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||||
|
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
|
||||||
|
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||||
|
func broadcastGroupMessage(_ envelope: Data) {}
|
||||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||||
func sendBoardPayload(_ payload: Data) {}
|
|
||||||
|
|
||||||
// Mesh diagnostics are mesh-transport-only; other transports report
|
// Mesh diagnostics are mesh-transport-only; other transports report
|
||||||
// "no reply"/"no path" rather than pretending to measure anything.
|
// "no reply"/"no path" rather than pretending to measure anything.
|
||||||
@@ -239,6 +238,7 @@ extension Transport {
|
|||||||
}
|
}
|
||||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
||||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||||
|
func sendBoardPayload(_ payload: Data) {}
|
||||||
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) {}
|
||||||
|
|||||||
@@ -242,16 +242,6 @@ enum TransportConfig {
|
|||||||
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
||||||
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
|
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
|
||||||
|
|
||||||
// Background wake-on-proximity (iOS). Pending connects issued on
|
|
||||||
// backgrounding never expire at the OS level: the Bluetooth controller
|
|
||||||
// completes them whenever the peer reappears in range and relaunches the
|
|
||||||
// app via state restoration. Entries older than the BLE address-rotation
|
|
||||||
// window no longer map to a reachable address, so the cache prunes them.
|
|
||||||
static let bleRecentPeripheralCacheCap: Int = 16
|
|
||||||
static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60
|
|
||||||
// Central slots kept free for connects driven by live background discovery
|
|
||||||
static let bleBackgroundPendingConnectSlotReserve: Int = 2
|
|
||||||
|
|
||||||
// Weak-link cooldown after connection timeouts
|
// Weak-link cooldown after connection timeouts
|
||||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||||
static let bleWeakLinkRSSICutoff: Int = -90
|
static let bleWeakLinkRSSICutoff: Int = -90
|
||||||
@@ -315,6 +305,21 @@ enum TransportConfig {
|
|||||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||||
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
|
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
|
||||||
|
// BLE stays the control plane: offers/responses ride the Noise session,
|
||||||
|
// only the sealed chunk stream moves to TCP over AWDL.
|
||||||
|
static let wifiBulkEnabled: Bool = true
|
||||||
|
// Below this size BLE fragmentation is fast enough that negotiation
|
||||||
|
// overhead isn't worth it.
|
||||||
|
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
|
||||||
|
static let wifiBulkChunkBytes: Int = 64 * 1024
|
||||||
|
// Offer unanswered for this long → fall back to BLE fragmentation.
|
||||||
|
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
|
||||||
|
// Hard ceiling on how long the Bonjour listener/connection may live.
|
||||||
|
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
|
||||||
|
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
|
||||||
|
static let wifiBulkMaxConcurrentIncoming: Int = 4
|
||||||
|
|
||||||
// Courier store-and-forward
|
// Courier store-and-forward
|
||||||
// Initial spray-and-wait budget per deposited envelope: each courier may
|
// Initial spray-and-wait budget per deposited envelope: each courier may
|
||||||
// hand half its remaining copies to another courier on encounter, so a
|
// hand half its remaining copies to another courier on encounter, so a
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkChannel.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
|
||||||
|
/// the connection's dispatch queue.
|
||||||
|
enum WifiBulkStream {
|
||||||
|
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
|
||||||
|
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
|
||||||
|
chunkBytes + WifiBulkCrypto.frameOverhead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads frames until `onFrame` returns false (stop) or the stream
|
||||||
|
/// errors/closes. `onFrame` returning true keeps the loop alive.
|
||||||
|
static func readFrames(
|
||||||
|
on connection: NWConnection,
|
||||||
|
buffer: WifiBulkFrameBuffer,
|
||||||
|
maxFrameBodyBytes: Int,
|
||||||
|
onFrame: @escaping (Data) -> Bool,
|
||||||
|
onError: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
// Drain any frames already buffered before touching the socket.
|
||||||
|
do {
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
guard onFrame(body) else { return }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError("frame decode failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.receive(
|
||||||
|
minimumIncompleteLength: 1,
|
||||||
|
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
|
||||||
|
) { data, _, isComplete, error in
|
||||||
|
if let data, !data.isEmpty {
|
||||||
|
buffer.append(data)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
onError("receive failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isComplete {
|
||||||
|
// Peer closed: hand over whatever complete frames remain, then
|
||||||
|
// report the close (sessions that already got what they need
|
||||||
|
// will have stopped the loop from inside onFrame).
|
||||||
|
do {
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
guard onFrame(body) else { return }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError("frame decode failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onError("connection closed by peer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: maxFrameBodyBytes,
|
||||||
|
onFrame: onFrame,
|
||||||
|
onError: onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
|
||||||
|
/// listener, requires the first inbound frame to prove knowledge of the
|
||||||
|
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
|
||||||
|
/// receiver's verified receipt.
|
||||||
|
///
|
||||||
|
/// The listener starts at offer time (Bonjour registration takes a moment)
|
||||||
|
/// but data can only flow after `activate(key:)` supplies the channel key
|
||||||
|
/// derived from the accepted response.
|
||||||
|
final class WifiBulkSenderSession {
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let payload: Data
|
||||||
|
private let transferID: Data
|
||||||
|
private let payloadHash: Data
|
||||||
|
private let chunkBytes: Int
|
||||||
|
private let parameters: NWParameters
|
||||||
|
private let service: NWListener.Service?
|
||||||
|
private let maxCandidateConnections = 4
|
||||||
|
|
||||||
|
private var key: SymmetricKey?
|
||||||
|
private var listener: NWListener?
|
||||||
|
/// Connections that have not yet produced a valid auth frame.
|
||||||
|
private var candidates: [NWConnection] = []
|
||||||
|
private var authenticated: NWConnection?
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
let totalChunks: Int
|
||||||
|
|
||||||
|
/// Test hook: fires once the listener is ready, with its bound port.
|
||||||
|
var onListenerReady: ((UInt16) -> Void)?
|
||||||
|
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
|
||||||
|
var onCompleted: (() -> Void)?
|
||||||
|
var onFailed: ((String) -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
payload: Data,
|
||||||
|
transferID: Data,
|
||||||
|
chunkBytes: Int,
|
||||||
|
parameters: NWParameters,
|
||||||
|
service: NWListener.Service?,
|
||||||
|
queue: DispatchQueue
|
||||||
|
) {
|
||||||
|
self.payload = payload
|
||||||
|
self.transferID = transferID
|
||||||
|
self.payloadHash = Data(SHA256.hash(data: payload))
|
||||||
|
self.chunkBytes = chunkBytes
|
||||||
|
self.parameters = parameters
|
||||||
|
self.service = service
|
||||||
|
self.queue = queue
|
||||||
|
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancelNetworkResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the listener. Returns false when the listener cannot be created
|
||||||
|
/// (caller falls back to BLE immediately).
|
||||||
|
func start() -> Bool {
|
||||||
|
let listener: NWListener
|
||||||
|
do {
|
||||||
|
listener = try NWListener(using: parameters)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
listener.service = service
|
||||||
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if let port = listener.port?.rawValue {
|
||||||
|
self.onListenerReady?(port)
|
||||||
|
}
|
||||||
|
case .failed(let error):
|
||||||
|
self.fail("listener failed: \(error)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener.newConnectionHandler = { [weak self] connection in
|
||||||
|
self?.acceptCandidate(connection)
|
||||||
|
}
|
||||||
|
self.listener = listener
|
||||||
|
listener.start(queue: queue)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplies the channel key once the receiver accepted the offer; begins
|
||||||
|
/// authenticating any connections that raced ahead of the response.
|
||||||
|
func activate(key: SymmetricKey) {
|
||||||
|
guard !finished, self.key == nil else { return }
|
||||||
|
self.key = key
|
||||||
|
for candidate in candidates {
|
||||||
|
beginAuthentication(on: candidate, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
finished = true
|
||||||
|
cancelNetworkResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection handling
|
||||||
|
|
||||||
|
private func acceptCandidate(_ connection: NWConnection) {
|
||||||
|
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
|
||||||
|
connection.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates.append(connection)
|
||||||
|
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||||
|
guard let self, let connection else { return }
|
||||||
|
if case .failed = state {
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
if let key {
|
||||||
|
beginAuthentication(on: connection, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dropCandidate(_ connection: NWConnection) {
|
||||||
|
if let index = candidates.firstIndex(where: { $0 === connection }) {
|
||||||
|
candidates.remove(at: index)
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self, weak connection] body in
|
||||||
|
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
|
||||||
|
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
|
||||||
|
// Bonjour-level gatecrasher: no channel key, no service.
|
||||||
|
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError: { [weak self, weak connection] _ in
|
||||||
|
guard let self, let connection, self.authenticated !== connection else { return }
|
||||||
|
self.dropCandidate(connection)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
|
||||||
|
authenticated = connection
|
||||||
|
// One authenticated peer is all a transfer needs: stop advertising and
|
||||||
|
// shed the other candidates.
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
for candidate in candidates where candidate !== connection {
|
||||||
|
candidate.cancel()
|
||||||
|
}
|
||||||
|
candidates.removeAll()
|
||||||
|
SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport)
|
||||||
|
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Streaming
|
||||||
|
|
||||||
|
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
|
||||||
|
guard !finished else { return }
|
||||||
|
guard index < totalChunks else {
|
||||||
|
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
|
||||||
|
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
|
||||||
|
let chunk = Data(payload[start..<end])
|
||||||
|
|
||||||
|
let body: Data
|
||||||
|
do {
|
||||||
|
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
|
||||||
|
} catch {
|
||||||
|
fail("chunk seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
if let error {
|
||||||
|
self.fail("send failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.onChunkSent?(index + 1, self.totalChunks)
|
||||||
|
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self] body in
|
||||||
|
guard let self, !self.finished else { return false }
|
||||||
|
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
|
||||||
|
self.fail("invalid receipt frame")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.finished = true
|
||||||
|
self.cancelNetworkResources()
|
||||||
|
self.onCompleted?()
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError: { [weak self] reason in
|
||||||
|
self?.fail("receipt wait failed: \(reason)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Teardown
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
cancelNetworkResources()
|
||||||
|
onFailed?(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelNetworkResources() {
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
authenticated?.cancel()
|
||||||
|
authenticated = nil
|
||||||
|
for candidate in candidates {
|
||||||
|
candidate.cancel()
|
||||||
|
}
|
||||||
|
candidates.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver side of the bulk channel: connects to the sender's per-transfer
|
||||||
|
/// endpoint, proves knowledge of the channel key with the first frame, then
|
||||||
|
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
|
||||||
|
final class WifiBulkReceiverSession {
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private let connection: NWConnection
|
||||||
|
private let key: SymmetricKey
|
||||||
|
private let transferID: Data
|
||||||
|
private let payloadHash: Data
|
||||||
|
private let chunkBytes: Int
|
||||||
|
private let assembler: WifiBulkPayloadAssembler
|
||||||
|
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
var onCompleted: ((Data) -> Void)?
|
||||||
|
var onFailed: ((String) -> Void)?
|
||||||
|
|
||||||
|
/// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver
|
||||||
|
/// enforces the cap it advertised, not the sender's word.
|
||||||
|
init?(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
parameters: NWParameters,
|
||||||
|
key: SymmetricKey,
|
||||||
|
transferID: Data,
|
||||||
|
expectedSize: UInt64,
|
||||||
|
expectedHash: Data,
|
||||||
|
sizeCap: Int,
|
||||||
|
chunkBytes: Int,
|
||||||
|
queue: DispatchQueue
|
||||||
|
) {
|
||||||
|
guard let assembler = WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: expectedSize,
|
||||||
|
expectedHash: expectedHash,
|
||||||
|
sizeCap: sizeCap
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.assembler = assembler
|
||||||
|
self.connection = NWConnection(to: endpoint, using: parameters)
|
||||||
|
self.key = key
|
||||||
|
self.transferID = transferID
|
||||||
|
self.payloadHash = expectedHash
|
||||||
|
self.chunkBytes = chunkBytes
|
||||||
|
self.queue = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport)
|
||||||
|
self.sendAuthFrameAndReceive()
|
||||||
|
case .failed(let error):
|
||||||
|
SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport)
|
||||||
|
self.fail("connect failed: \(error)")
|
||||||
|
case .waiting(let error):
|
||||||
|
// .waiting can resolve on its own, but a per-transfer channel
|
||||||
|
// has a peer actively listening; treat unreachable as fatal so
|
||||||
|
// the sender's fallback isn't left to the window timeout alone.
|
||||||
|
self.fail("connection waiting: \(error)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
finished = true
|
||||||
|
connection.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAuthFrameAndReceive() {
|
||||||
|
guard !finished else { return }
|
||||||
|
let authBody: Data
|
||||||
|
do {
|
||||||
|
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||||
|
} catch {
|
||||||
|
fail("auth frame seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
if let error {
|
||||||
|
self.fail("auth frame send failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.receiveChunks()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveChunks() {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||||
|
WifiBulkStream.readFrames(
|
||||||
|
on: connection,
|
||||||
|
buffer: buffer,
|
||||||
|
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||||
|
onFrame: { [weak self] body in
|
||||||
|
guard let self, !self.finished else { return false }
|
||||||
|
do {
|
||||||
|
guard let payload = try self.assembler.consume(frameBody: body) else {
|
||||||
|
return true // keep reading
|
||||||
|
}
|
||||||
|
self.sendReceiptAndComplete(payload)
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
self.fail("chunk rejected: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: { [weak self] reason in
|
||||||
|
self?.fail(reason)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendReceiptAndComplete(_ payload: Data) {
|
||||||
|
let receiptBody: Data
|
||||||
|
do {
|
||||||
|
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
|
||||||
|
} catch {
|
||||||
|
fail("receipt seal failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
|
||||||
|
// Receipt is best-effort from the receiver's perspective: the
|
||||||
|
// payload is already verified. Close the channel either way.
|
||||||
|
guard let self, !self.finished else { return }
|
||||||
|
self.finished = true
|
||||||
|
self.connection.cancel()
|
||||||
|
self.onCompleted?(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
connection.cancel()
|
||||||
|
onFailed?(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkCrypto.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Channel security for the Wi-Fi bulk data plane.
|
||||||
|
///
|
||||||
|
/// The TCP stream is encrypted and authenticated independently of TLS: both
|
||||||
|
/// endpoints exchanged random 32-byte tokens inside the established Noise
|
||||||
|
/// session, so only they can derive the ChaChaPoly channel key via
|
||||||
|
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
|
||||||
|
/// gatecrasher that connects to the listener cannot produce a single valid
|
||||||
|
/// frame and is disconnected.
|
||||||
|
///
|
||||||
|
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
|
||||||
|
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
|
||||||
|
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
|
||||||
|
/// and the reader requires the exact expected nonce for each frame, so frames
|
||||||
|
/// cannot be replayed, reordered, or reflected across directions.
|
||||||
|
enum WifiBulkCryptoError: Error, Equatable {
|
||||||
|
case invalidParameters
|
||||||
|
case frameTooLarge
|
||||||
|
case truncatedFrame
|
||||||
|
case nonceMismatch
|
||||||
|
case authenticationFailed
|
||||||
|
case emptyChunk
|
||||||
|
case payloadOverflow
|
||||||
|
case hashMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WifiBulkFrameDirection: UInt8 {
|
||||||
|
/// Data chunks: counters 0, 1, 2, …
|
||||||
|
case senderToReceiver = 0x00
|
||||||
|
/// Counter 0 = client auth frame, counter 1 = final receipt.
|
||||||
|
case receiverToSender = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WifiBulkCrypto {
|
||||||
|
static let keyDomain = "bitchat-bulk-v1"
|
||||||
|
static let nonceLength = 12
|
||||||
|
static let tagLength = 16
|
||||||
|
/// AEAD overhead per frame body (nonce + tag).
|
||||||
|
static let frameOverhead = nonceLength + tagLength
|
||||||
|
/// 4-byte big-endian length prefix per frame.
|
||||||
|
static let framePrefixLength = 4
|
||||||
|
|
||||||
|
// MARK: Key derivation
|
||||||
|
|
||||||
|
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
|
||||||
|
/// Deterministic: same tokens + transferID always yield the same key.
|
||||||
|
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
|
||||||
|
guard senderToken.count == WifiBulkWire.tokenLength,
|
||||||
|
receiverToken.count == WifiBulkWire.tokenLength,
|
||||||
|
transferID.count == WifiBulkWire.transferIDLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var inputKeyMaterial = Data()
|
||||||
|
inputKeyMaterial.append(senderToken)
|
||||||
|
inputKeyMaterial.append(receiverToken)
|
||||||
|
return HKDF<SHA256>.deriveKey(
|
||||||
|
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
|
||||||
|
salt: transferID,
|
||||||
|
info: Data(keyDomain.utf8),
|
||||||
|
outputByteCount: 32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame sealing
|
||||||
|
|
||||||
|
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
|
||||||
|
var nonce = Data(count: nonceLength)
|
||||||
|
nonce[0] = direction.rawValue
|
||||||
|
var counterBE = counter.bigEndian
|
||||||
|
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
|
||||||
|
return nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
|
||||||
|
static func sealFrameBody(
|
||||||
|
_ plaintext: Data,
|
||||||
|
direction: WifiBulkFrameDirection,
|
||||||
|
counter: UInt64,
|
||||||
|
key: SymmetricKey
|
||||||
|
) throws -> Data {
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
|
||||||
|
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens one frame body, enforcing the exact expected nonce.
|
||||||
|
static func openFrameBody(
|
||||||
|
_ body: Data,
|
||||||
|
direction: WifiBulkFrameDirection,
|
||||||
|
counter: UInt64,
|
||||||
|
key: SymmetricKey
|
||||||
|
) throws -> Data {
|
||||||
|
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
|
||||||
|
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
|
||||||
|
throw WifiBulkCryptoError.nonceMismatch
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let box = try ChaChaPoly.SealedBox(combined: body)
|
||||||
|
return try ChaChaPoly.open(box, using: key)
|
||||||
|
} catch {
|
||||||
|
throw WifiBulkCryptoError.authenticationFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
|
||||||
|
static func frameData(body: Data) -> Data {
|
||||||
|
var framed = Data(capacity: framePrefixLength + body.count)
|
||||||
|
var lengthBE = UInt32(body.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
|
||||||
|
framed.append(body)
|
||||||
|
return framed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Control frames
|
||||||
|
|
||||||
|
/// First frame on the wire, receiver → sender: proves the connecting
|
||||||
|
/// client holds the Noise-exchanged secret before any data flows.
|
||||||
|
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
|
||||||
|
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final frame, receiver → sender: acknowledges the fully verified payload.
|
||||||
|
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
|
||||||
|
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
|
||||||
|
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
|
||||||
|
final class WifiBulkFrameBuffer {
|
||||||
|
private var buffer = Data()
|
||||||
|
private let maxBodyBytes: Int
|
||||||
|
|
||||||
|
init(maxBodyBytes: Int) {
|
||||||
|
self.maxBodyBytes = maxBodyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ data: Data) {
|
||||||
|
buffer.append(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the next complete frame body, or nil when more bytes are needed.
|
||||||
|
func nextFrameBody() throws -> Data? {
|
||||||
|
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
|
||||||
|
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
|
||||||
|
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
|
||||||
|
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
|
||||||
|
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
|
||||||
|
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver-side reassembly: opens sequential data frames, enforces the size
|
||||||
|
/// negotiated in the accepted offer, and verifies the final SHA-256.
|
||||||
|
final class WifiBulkPayloadAssembler {
|
||||||
|
private let key: SymmetricKey
|
||||||
|
private let expectedSize: Int
|
||||||
|
private let expectedHash: Data
|
||||||
|
private var received = Data()
|
||||||
|
private var counter: UInt64 = 0
|
||||||
|
|
||||||
|
/// Fails when the offer exceeds the receiver-enforced cap.
|
||||||
|
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
|
||||||
|
guard expectedSize > 0,
|
||||||
|
expectedSize <= UInt64(sizeCap),
|
||||||
|
expectedHash.count == WifiBulkWire.hashLength else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.key = key
|
||||||
|
self.expectedSize = Int(expectedSize)
|
||||||
|
self.expectedHash = expectedHash
|
||||||
|
}
|
||||||
|
|
||||||
|
var isComplete: Bool { received.count == expectedSize }
|
||||||
|
|
||||||
|
/// Consumes one sealed data frame body. Returns the verified payload when
|
||||||
|
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
|
||||||
|
func consume(frameBody: Data) throws -> Data? {
|
||||||
|
let chunk = try WifiBulkCrypto.openFrameBody(
|
||||||
|
frameBody,
|
||||||
|
direction: .senderToReceiver,
|
||||||
|
counter: counter,
|
||||||
|
key: key
|
||||||
|
)
|
||||||
|
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
|
||||||
|
counter += 1
|
||||||
|
guard received.count + chunk.count <= expectedSize else {
|
||||||
|
throw WifiBulkCryptoError.payloadOverflow
|
||||||
|
}
|
||||||
|
received.append(chunk)
|
||||||
|
guard isComplete else { return nil }
|
||||||
|
guard Data(SHA256.hash(data: received)) == expectedHash else {
|
||||||
|
throw WifiBulkCryptoError.hashMismatch
|
||||||
|
}
|
||||||
|
return received
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkMessages.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
|
||||||
|
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
|
||||||
|
///
|
||||||
|
/// Both messages ride the encrypted Noise channel, so every field — including
|
||||||
|
/// the session tokens and the random Bonjour instance name — is only visible
|
||||||
|
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
|
||||||
|
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
|
||||||
|
/// compatibility.
|
||||||
|
enum WifiBulkWire {
|
||||||
|
static let transferIDLength = 16
|
||||||
|
static let tokenLength = 32
|
||||||
|
static let hashLength = 32
|
||||||
|
/// Bonjour instance names are capped at 63 UTF-8 bytes.
|
||||||
|
static let maxServiceNameBytes = 63
|
||||||
|
|
||||||
|
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
|
||||||
|
data.append(type)
|
||||||
|
var length = UInt16(value.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
|
||||||
|
/// Returns false when the buffer is structurally malformed.
|
||||||
|
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
while cursor < end {
|
||||||
|
let type = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
guard data.distance(from: cursor, to: end) >= 2 else { return false }
|
||||||
|
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||||
|
cursor = data.index(cursor, offsetBy: 2)
|
||||||
|
guard data.distance(from: cursor, to: end) >= length else { return false }
|
||||||
|
let valueEnd = data.index(cursor, offsetBy: length)
|
||||||
|
visit(type, Data(data[cursor..<valueEnd]))
|
||||||
|
cursor = valueEnd
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender → receiver: proposal to move an already-encoded file payload over
|
||||||
|
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
|
||||||
|
struct WifiBulkOffer: Equatable {
|
||||||
|
/// Random per-transfer identifier; also the HKDF salt.
|
||||||
|
let transferID: Data
|
||||||
|
/// Exact byte count of the payload that will cross the channel.
|
||||||
|
let fileSize: UInt64
|
||||||
|
/// SHA-256 over the payload bytes as they cross the channel, verified by
|
||||||
|
/// the receiver after reassembly.
|
||||||
|
let payloadHash: Data
|
||||||
|
/// Sender's random half of the channel secret.
|
||||||
|
let token: Data
|
||||||
|
/// Random Bonjour instance name the sender publishes for this transfer.
|
||||||
|
/// Never derived from nickname or peer ID.
|
||||||
|
let serviceName: String
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case transferID = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case payloadHash = 0x03
|
||||||
|
case token = 0x04
|
||||||
|
case serviceName = 0x05
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard transferID.count == WifiBulkWire.transferIDLength,
|
||||||
|
payloadHash.count == WifiBulkWire.hashLength,
|
||||||
|
token.count == WifiBulkWire.tokenLength else { return nil }
|
||||||
|
let nameData = Data(serviceName.utf8)
|
||||||
|
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||||
|
var sizeBE = fileSize.bigEndian
|
||||||
|
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WifiBulkOffer? {
|
||||||
|
var transferID: Data?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var payloadHash: Data?
|
||||||
|
var token: Data?
|
||||||
|
var serviceName: String?
|
||||||
|
|
||||||
|
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||||
|
transferID = value
|
||||||
|
case .fileSize where value.count == 8:
|
||||||
|
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .payloadHash where value.count == WifiBulkWire.hashLength:
|
||||||
|
payloadHash = value
|
||||||
|
case .token where value.count == WifiBulkWire.tokenLength:
|
||||||
|
token = value
|
||||||
|
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
|
||||||
|
serviceName = String(data: value, encoding: .utf8)
|
||||||
|
default:
|
||||||
|
break // Unknown or malformed field: ignore; required checks below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard wellFormed,
|
||||||
|
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return WifiBulkOffer(
|
||||||
|
transferID: transferID,
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: payloadHash,
|
||||||
|
token: token,
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver → sender: accept (with the receiver's token half) or decline.
|
||||||
|
struct WifiBulkResponse: Equatable {
|
||||||
|
let transferID: Data
|
||||||
|
let accepted: Bool
|
||||||
|
/// Receiver's random half of the channel secret; present iff accepted.
|
||||||
|
let token: Data?
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case transferID = 0x01
|
||||||
|
case accepted = 0x02
|
||||||
|
case token = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
|
||||||
|
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decline(transferID: Data) -> WifiBulkResponse {
|
||||||
|
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
|
||||||
|
if accepted {
|
||||||
|
guard token?.count == WifiBulkWire.tokenLength else { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||||
|
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
|
||||||
|
if accepted, let token {
|
||||||
|
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WifiBulkResponse? {
|
||||||
|
var transferID: Data?
|
||||||
|
var accepted: Bool?
|
||||||
|
var token: Data?
|
||||||
|
|
||||||
|
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||||
|
transferID = value
|
||||||
|
case .accepted where value.count == 1:
|
||||||
|
accepted = value.first == 1
|
||||||
|
case .token where value.count == WifiBulkWire.tokenLength:
|
||||||
|
token = value
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard wellFormed, let transferID, let accepted else { return nil }
|
||||||
|
if accepted {
|
||||||
|
guard let token else { return nil }
|
||||||
|
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||||
|
}
|
||||||
|
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkPolicy.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
|
||||||
|
/// fails these gates rides BLE fragmentation exactly as before — the BLE
|
||||||
|
/// fallback is the common case and must stay bulletproof.
|
||||||
|
enum WifiBulkPolicy {
|
||||||
|
struct SendCandidate {
|
||||||
|
let payloadBytes: Int
|
||||||
|
let peerCapabilities: PeerCapabilities
|
||||||
|
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
|
||||||
|
/// only reaches direct neighbors, and relays can't proxy the channel.
|
||||||
|
let isDirectlyConnected: Bool
|
||||||
|
/// The offer rides the Noise session, so one must already exist.
|
||||||
|
let hasEstablishedNoiseSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldOffer(
|
||||||
|
_ candidate: SendCandidate,
|
||||||
|
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||||
|
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
|
||||||
|
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) -> Bool {
|
||||||
|
enabled
|
||||||
|
&& candidate.payloadBytes > minPayloadBytes
|
||||||
|
&& candidate.payloadBytes <= maxPayloadBytes
|
||||||
|
&& candidate.peerCapabilities.contains(.wifiBulk)
|
||||||
|
&& candidate.isDirectlyConnected
|
||||||
|
&& candidate.hasEstablishedNoiseSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver-side gate. Field lengths were validated at decode; this
|
||||||
|
/// enforces the size cap (from the local ceiling, not the sender's word)
|
||||||
|
/// and local enablement.
|
||||||
|
static func shouldAccept(
|
||||||
|
offer: WifiBulkOffer,
|
||||||
|
senderIsDirectlyConnected: Bool,
|
||||||
|
activeIncomingTransfers: Int,
|
||||||
|
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||||
|
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
|
||||||
|
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||||
|
) -> Bool {
|
||||||
|
enabled
|
||||||
|
&& senderIsDirectlyConnected
|
||||||
|
&& activeIncomingTransfers < maxConcurrentIncoming
|
||||||
|
&& offer.fileSize > 0
|
||||||
|
&& offer.fileSize <= UInt64(maxPayloadBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkTransferService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
|
||||||
|
/// hops live inside the closures supplied by `BLEService`, keeping this
|
||||||
|
/// service independently testable.
|
||||||
|
struct WifiBulkTransferServiceEnvironment {
|
||||||
|
/// Sends a typed payload inside the established Noise session with the
|
||||||
|
/// peer. Returns false when no established session exists (the caller
|
||||||
|
/// falls back to BLE).
|
||||||
|
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
|
||||||
|
/// Whether the peer is on a direct BLE link right now.
|
||||||
|
let isPeerConnected: (PeerID) -> Bool
|
||||||
|
/// Delivers a fully received, hash-verified payload (encoded
|
||||||
|
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
|
||||||
|
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
|
||||||
|
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
|
||||||
|
/// unchanged (chunks report as "fragments").
|
||||||
|
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
|
||||||
|
let progressChunkSent: (_ transferId: String) -> Void
|
||||||
|
/// Silently forgets progress state ahead of a BLE fallback re-start.
|
||||||
|
let progressReset: (_ transferId: String) -> Void
|
||||||
|
/// Emits the cancelled event for user-cancelled transfers.
|
||||||
|
let progressCancel: (_ transferId: String) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knobs with test overrides; production values come from `TransportConfig`.
|
||||||
|
struct WifiBulkTransferServiceConfig {
|
||||||
|
var serviceType: String = TransportConfig.wifiBulkServiceType
|
||||||
|
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
|
||||||
|
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
|
||||||
|
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
|
||||||
|
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||||
|
/// Tests disable peer-to-peer so loopback interfaces stay usable.
|
||||||
|
var usePeerToPeer: Bool = true
|
||||||
|
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
|
||||||
|
var publishBonjourService: Bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
|
||||||
|
/// response (control plane), then the payload crosses a per-transfer TCP
|
||||||
|
/// channel over AWDL, sealed with a key both sides derived from the
|
||||||
|
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
|
||||||
|
/// fragmentation exactly once; the receiver side fails silently and lets the
|
||||||
|
/// sender's timeout drive that fallback.
|
||||||
|
final class WifiBulkTransferService {
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
|
||||||
|
private let environment: WifiBulkTransferServiceEnvironment
|
||||||
|
private let config: WifiBulkTransferServiceConfig
|
||||||
|
|
||||||
|
private final class OutgoingTransfer {
|
||||||
|
let transferID: Data
|
||||||
|
let transferId: String
|
||||||
|
let peerID: PeerID
|
||||||
|
let token: Data
|
||||||
|
let fallback: () -> Void
|
||||||
|
var session: WifiBulkSenderSession?
|
||||||
|
var offerTimeout: DispatchWorkItem?
|
||||||
|
var windowTimeout: DispatchWorkItem?
|
||||||
|
var accepted = false
|
||||||
|
var finished = false
|
||||||
|
|
||||||
|
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
|
||||||
|
self.transferID = transferID
|
||||||
|
self.transferId = transferId
|
||||||
|
self.peerID = peerID
|
||||||
|
self.token = token
|
||||||
|
self.fallback = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class IncomingTransfer {
|
||||||
|
let offer: WifiBulkOffer
|
||||||
|
let peerID: PeerID
|
||||||
|
let key: SymmetricKey
|
||||||
|
var browser: NWBrowser?
|
||||||
|
var session: WifiBulkReceiverSession?
|
||||||
|
var windowTimeout: DispatchWorkItem?
|
||||||
|
|
||||||
|
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
|
||||||
|
self.offer = offer
|
||||||
|
self.peerID = peerID
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outgoing: [Data: OutgoingTransfer] = [:]
|
||||||
|
private var incoming: [Data: IncomingTransfer] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
environment: WifiBulkTransferServiceEnvironment,
|
||||||
|
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sender
|
||||||
|
|
||||||
|
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
|
||||||
|
/// most once, on decline, timeout, or any mid-transfer error.
|
||||||
|
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a decrypted `bulkTransferResponse` Noise payload.
|
||||||
|
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.processResponse(payload, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
|
||||||
|
func cancelTransfer(transferId: String) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tears down every transfer (service shutdown / emergency disconnect).
|
||||||
|
/// In-flight outgoing transfers do NOT fall back — the transport is going away.
|
||||||
|
func stop() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for transfer in self.outgoing.values {
|
||||||
|
transfer.finished = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.session?.cancel()
|
||||||
|
}
|
||||||
|
self.outgoing.removeAll()
|
||||||
|
for transfer in self.incoming.values {
|
||||||
|
self.tearDownIncomingResources(transfer)
|
||||||
|
}
|
||||||
|
self.incoming.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum OutgoingOutcome {
|
||||||
|
case completed
|
||||||
|
case fallback
|
||||||
|
case cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||||
|
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
|
||||||
|
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||||
|
// Random per-transfer instance name — never the nickname or peer ID.
|
||||||
|
let serviceName = Self.randomData(16).hexEncodedString()
|
||||||
|
|
||||||
|
let offer = WifiBulkOffer(
|
||||||
|
transferID: transferID,
|
||||||
|
fileSize: UInt64(payload.count),
|
||||||
|
payloadHash: Data(SHA256.hash(data: payload)),
|
||||||
|
token: token,
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
guard let offerData = offer.encode() else {
|
||||||
|
SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport)
|
||||||
|
fallbackToBLE()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = OutgoingTransfer(
|
||||||
|
transferID: transferID,
|
||||||
|
transferId: transferId,
|
||||||
|
peerID: peerID,
|
||||||
|
token: token,
|
||||||
|
fallback: fallbackToBLE
|
||||||
|
)
|
||||||
|
|
||||||
|
let session = WifiBulkSenderSession(
|
||||||
|
payload: payload,
|
||||||
|
transferID: transferID,
|
||||||
|
chunkBytes: config.chunkBytes,
|
||||||
|
parameters: makeParameters(),
|
||||||
|
service: config.publishBonjourService
|
||||||
|
? NWListener.Service(name: serviceName, type: config.serviceType)
|
||||||
|
: nil,
|
||||||
|
queue: queue
|
||||||
|
)
|
||||||
|
if let onListenerReady = _test_onListenerReady {
|
||||||
|
session.onListenerReady = { port in onListenerReady(transferID, port) }
|
||||||
|
}
|
||||||
|
session.onChunkSent = { [weak self, weak transfer] sent, total in
|
||||||
|
guard let self, let transfer, !transfer.finished else { return }
|
||||||
|
// Hold the final tick until the receipt confirms delivery, so the
|
||||||
|
// progress bus only emits .completed for verified transfers.
|
||||||
|
if sent < total {
|
||||||
|
self.environment.progressChunkSent(transfer.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.onCompleted = { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer, !transfer.finished else { return }
|
||||||
|
self.environment.progressChunkSent(transfer.transferId)
|
||||||
|
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
|
||||||
|
}
|
||||||
|
session.onFailed = { [weak self, weak transfer] reason in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
|
||||||
|
}
|
||||||
|
transfer.session = session
|
||||||
|
outgoing[transferID] = transfer
|
||||||
|
|
||||||
|
guard session.start() else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
|
||||||
|
peerID
|
||||||
|
) else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .transport)
|
||||||
|
environment.progressStart(transferId, session.totalChunks)
|
||||||
|
|
||||||
|
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer, !transfer.accepted else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
|
||||||
|
}
|
||||||
|
transfer.offerTimeout = offerTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
|
||||||
|
|
||||||
|
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
|
||||||
|
}
|
||||||
|
transfer.windowTimeout = windowTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processResponse(_ payload: Data, from peerID: PeerID) {
|
||||||
|
guard let response = WifiBulkResponse.decode(payload),
|
||||||
|
let transfer = outgoing[response.transferID],
|
||||||
|
transfer.peerID.toShort() == peerID.toShort(),
|
||||||
|
!transfer.accepted, !transfer.finished else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard response.accepted, let receiverToken = response.token else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let key = WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: transfer.token,
|
||||||
|
receiverToken: receiverToken,
|
||||||
|
transferID: transfer.transferID
|
||||||
|
) else {
|
||||||
|
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer.accepted = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.offerTimeout = nil
|
||||||
|
SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport)
|
||||||
|
transfer.session?.activate(key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
|
||||||
|
guard !transfer.finished else { return }
|
||||||
|
transfer.finished = true
|
||||||
|
transfer.offerTimeout?.cancel()
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.session?.cancel()
|
||||||
|
outgoing.removeValue(forKey: transfer.transferID)
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case .completed:
|
||||||
|
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
|
||||||
|
case .fallback:
|
||||||
|
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
|
||||||
|
environment.progressReset(transfer.transferId)
|
||||||
|
transfer.fallback()
|
||||||
|
case .cancelled:
|
||||||
|
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
|
||||||
|
environment.progressCancel(transfer.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receiver
|
||||||
|
|
||||||
|
/// Handles a decrypted `bulkTransferOffer` Noise payload.
|
||||||
|
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.processOffer(payload, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processOffer(_ payload: Data, from peerID: PeerID) {
|
||||||
|
guard let offer = WifiBulkOffer.decode(payload) else { return }
|
||||||
|
guard incoming[offer.transferID] == nil else { return }
|
||||||
|
|
||||||
|
guard WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer,
|
||||||
|
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
|
||||||
|
activeIncomingTransfers: incoming.count,
|
||||||
|
maxPayloadBytes: config.maxIncomingPayloadBytes,
|
||||||
|
maxConcurrentIncoming: config.maxConcurrentIncoming
|
||||||
|
) else {
|
||||||
|
decline(offer: offer, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||||
|
guard let key = WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: offer.token,
|
||||||
|
receiverToken: token,
|
||||||
|
transferID: offer.transferID
|
||||||
|
),
|
||||||
|
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
|
||||||
|
decline(offer: offer, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||||
|
peerID
|
||||||
|
) else {
|
||||||
|
return // No session to answer on; the sender's timeout handles fallback.
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||||
|
incoming[offer.transferID] = transfer
|
||||||
|
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||||
|
|
||||||
|
startBrowsing(for: transfer)
|
||||||
|
|
||||||
|
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
transfer.windowTimeout = windowTimeout
|
||||||
|
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||||
|
SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||||
|
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
|
||||||
|
_ = environment.sendNoisePayload(
|
||||||
|
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||||
|
peerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startBrowsing(for transfer: IncomingTransfer) {
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: config.serviceType, domain: nil),
|
||||||
|
using: makeParameters()
|
||||||
|
)
|
||||||
|
transfer.browser = browser
|
||||||
|
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
|
||||||
|
guard let self, let transfer, transfer.session == nil else { return }
|
||||||
|
let match = results.first { result in
|
||||||
|
if case .service(let name, _, _, _) = result.endpoint {
|
||||||
|
return name == transfer.offer.serviceName
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let match else { return }
|
||||||
|
self.connect(transfer, to: match.endpoint)
|
||||||
|
}
|
||||||
|
browser.stateUpdateHandler = { [weak self, weak transfer] state in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
if case .failed(let error) = state {
|
||||||
|
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browser.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test hook: connects an accepted incoming transfer straight to an
|
||||||
|
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
|
||||||
|
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
|
||||||
|
self.connect(transfer, to: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
|
||||||
|
transfer.browser?.cancel()
|
||||||
|
transfer.browser = nil
|
||||||
|
|
||||||
|
guard let session = WifiBulkReceiverSession(
|
||||||
|
endpoint: endpoint,
|
||||||
|
parameters: makeParameters(),
|
||||||
|
key: transfer.key,
|
||||||
|
transferID: transfer.offer.transferID,
|
||||||
|
expectedSize: transfer.offer.fileSize,
|
||||||
|
expectedHash: transfer.offer.payloadHash,
|
||||||
|
sizeCap: config.maxIncomingPayloadBytes,
|
||||||
|
chunkBytes: config.chunkBytes,
|
||||||
|
queue: queue
|
||||||
|
) else {
|
||||||
|
tearDownIncoming(transfer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.onCompleted = { [weak self, weak transfer] payload in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport)
|
||||||
|
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
session.onFailed = { [weak self, weak transfer] reason in
|
||||||
|
guard let self, let transfer else { return }
|
||||||
|
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
|
||||||
|
self.tearDownIncoming(transfer)
|
||||||
|
}
|
||||||
|
transfer.session = session
|
||||||
|
session.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownIncoming(_ transfer: IncomingTransfer) {
|
||||||
|
tearDownIncomingResources(transfer)
|
||||||
|
incoming.removeValue(forKey: transfer.offer.transferID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
|
||||||
|
transfer.windowTimeout?.cancel()
|
||||||
|
transfer.windowTimeout = nil
|
||||||
|
transfer.browser?.cancel()
|
||||||
|
transfer.browser = nil
|
||||||
|
transfer.session?.cancel()
|
||||||
|
transfer.session = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeParameters() -> NWParameters {
|
||||||
|
let parameters = NWParameters.tcp
|
||||||
|
if config.usePeerToPeer {
|
||||||
|
parameters.includePeerToPeer = true
|
||||||
|
// Keep the channel off infrastructure-independent radios we never
|
||||||
|
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
|
||||||
|
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
|
||||||
|
}
|
||||||
|
return parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
|
||||||
|
private static func randomData(_ count: Int) -> Data {
|
||||||
|
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test observability
|
||||||
|
|
||||||
|
/// Test hook: reports each outgoing listener's bound port, standing in
|
||||||
|
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
|
||||||
|
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
|
||||||
|
|
||||||
|
var _test_activeOutgoingCount: Int {
|
||||||
|
queue.sync { outgoing.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
var _test_activeIncomingCount: Int {
|
||||||
|
queue.sync { incoming.count }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,10 +212,10 @@ final class GossipSyncManager {
|
|||||||
// messages get the long town-crier window; fragments, file transfers and
|
// messages get the long town-crier window; fragments, file transfers and
|
||||||
// announces keep the short one.
|
// announces keep the short one.
|
||||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
// Group messages share the whole-message window: members off the mesh
|
|
||||||
// for a while should backfill their crew's history like public chat.
|
|
||||||
let maxAgeSeconds: TimeInterval
|
let maxAgeSeconds: TimeInterval
|
||||||
switch packet.type {
|
switch packet.type {
|
||||||
|
// Group messages share the whole-message window: members off the mesh
|
||||||
|
// for a while should backfill their crew's history like public chat.
|
||||||
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||||
case MessageType.prekeyBundle.rawValue:
|
case MessageType.prekeyBundle.rawValue:
|
||||||
@@ -275,13 +275,6 @@ final class GossipSyncManager {
|
|||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||||
case .groupMessage:
|
|
||||||
// Opaque ciphertext to non-members; carried and served like any
|
|
||||||
// other broadcast so members get backfill from any relay.
|
|
||||||
guard isBroadcastRecipient else { return }
|
|
||||||
guard isPacketFresh(packet) else { return }
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
|
||||||
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
|
|
||||||
case .prekeyBundle:
|
case .prekeyBundle:
|
||||||
// Callers only feed verified bundles here (own bundles at send
|
// Callers only feed verified bundles here (own bundles at send
|
||||||
// time, peers' after signature verification), so gossip never
|
// time, peers' after signature verification), so gossip never
|
||||||
@@ -305,6 +298,13 @@ final class GossipSyncManager {
|
|||||||
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
|
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
|
||||||
|
case .groupMessage:
|
||||||
|
// Opaque ciphertext to non-members; carried and served like any
|
||||||
|
// other broadcast so members get backfill from any relay.
|
||||||
|
guard isBroadcastRecipient else { return }
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,6 @@ final class GossipSyncManager {
|
|||||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||||
// Unicast sync to connected peers to allow RSR attribution
|
// Unicast sync to connected peers to allow RSR attribution
|
||||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||||
SecureLogger.debug("Sending periodic sync (\(types.logDescription)) to \(connectedPeers.count) connected peers", category: .sync)
|
|
||||||
for peerID in connectedPeers {
|
for peerID in connectedPeers {
|
||||||
sendRequestSync(to: peerID, types: types)
|
sendRequestSync(to: peerID, types: types)
|
||||||
}
|
}
|
||||||
@@ -350,7 +349,7 @@ final class GossipSyncManager {
|
|||||||
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
|
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
|
||||||
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
|
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
|
||||||
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
|
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
|
||||||
SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
|
SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh)
|
||||||
for peerID in connectedPeers {
|
for peerID in connectedPeers {
|
||||||
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
|
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
|
||||||
}
|
}
|
||||||
@@ -391,7 +390,7 @@ final class GossipSyncManager {
|
|||||||
// A response can replay the whole store, so bound how often one peer
|
// A response can replay the whole store, so bound how often one peer
|
||||||
// can trigger a diff pass regardless of how fast it asks.
|
// can trigger a diff pass regardless of how fast it asks.
|
||||||
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
||||||
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let requestedTypes = (request.types ?? .publicMessages)
|
let requestedTypes = (request.types ?? .publicMessages)
|
||||||
@@ -399,6 +398,9 @@ final class GossipSyncManager {
|
|||||||
// older packets are outside the filter but not missing, and without
|
// older packets are outside the filter but not missing, and without
|
||||||
// the cursor they would be re-sent every round.
|
// the cursor they would be re-sent every round.
|
||||||
let since = request.sinceTimestamp
|
let since = request.sinceTimestamp
|
||||||
|
// One concise per-request summary (RSR is already rate-limited per
|
||||||
|
// peer), never per-packet spam.
|
||||||
|
var servedCount = 0
|
||||||
// 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 {
|
||||||
@@ -418,6 +420,7 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,6 +435,7 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,6 +460,7 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,23 +475,11 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.groupMessage) {
|
|
||||||
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
|
||||||
for pkt in groupPkts {
|
|
||||||
if let since, pkt.timestamp < since { continue }
|
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
|
||||||
if !mightContain(idBytes) {
|
|
||||||
var toSend = pkt
|
|
||||||
toSend.ttl = 0
|
|
||||||
toSend.isRSR = true // Mark as solicited response
|
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||||
// there is at most one per owner (newer replaces older), so the
|
// there is at most one per owner (newer replaces older), so the
|
||||||
// resend cost is bounded and a joining peer must be able to learn
|
// resend cost is bounded and a joining peer must be able to learn
|
||||||
@@ -501,9 +494,26 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if requestedTypes.contains(.groupMessage) {
|
||||||
|
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
||||||
|
for pkt in groupPkts {
|
||||||
|
if let since, pkt.timestamp < since { continue }
|
||||||
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
toSend.isRSR = true // Mark as solicited response
|
||||||
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if requestedTypes.contains(.boardPost) {
|
if requestedTypes.contains(.boardPost) {
|
||||||
// The board store already filters to live posts and tombstones;
|
// The board store already filters to live posts and tombstones;
|
||||||
// no freshness window applies (posts sync until their own expiry).
|
// no freshness window applies (posts sync until their own expiry).
|
||||||
@@ -516,9 +526,14 @@ final class GossipSyncManager {
|
|||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
toSend.isRSR = true // Mark as solicited response
|
toSend.isRSR = true // Mark as solicited response
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
servedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if servedCount > 0 {
|
||||||
|
SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))…", category: .sync)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
@@ -538,14 +553,14 @@ final class GossipSyncManager {
|
|||||||
if types.contains(.fileTransfer) {
|
if types.contains(.fileTransfer) {
|
||||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||||
}
|
}
|
||||||
if types.contains(.groupMessage) {
|
|
||||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
|
||||||
}
|
|
||||||
if types.contains(.prekeyBundle) {
|
if types.contains(.prekeyBundle) {
|
||||||
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if types.contains(.groupMessage) {
|
||||||
|
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||||
|
}
|
||||||
if types.contains(.boardPost) {
|
if types.contains(.boardPost) {
|
||||||
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||||
}
|
}
|
||||||
@@ -610,10 +625,10 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
fragments.removeExpired(isFresh: isPacketFresh)
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
groupMessages.removeExpired(isFresh: isPacketFresh)
|
|
||||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Archive (public message persistence)
|
// MARK: - Archive (public message persistence)
|
||||||
@@ -662,6 +677,7 @@ final class GossipSyncManager {
|
|||||||
// One request per due schedule rather than a union filter: each type
|
// One request per due schedule rather than a union filter: each type
|
||||||
// group gets the full GCS capacity and its own since-cursor, so heavy
|
// group gets the full GCS capacity and its own since-cursor, so heavy
|
||||||
// fragment traffic can't crowd messages out of the filter.
|
// fragment traffic can't crowd messages out of the filter.
|
||||||
|
var dueLabels: [String] = []
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
// No board source wired up means nothing to offer or store;
|
// No board source wired up means nothing to offer or store;
|
||||||
@@ -670,8 +686,14 @@ final class GossipSyncManager {
|
|||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
sendPeriodicSync(for: syncSchedules[index].types)
|
||||||
|
dueLabels.append(syncSchedules[index].types.debugShortLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One concise summary per maintenance cycle, not per packet.
|
||||||
|
if !dueLabels.isEmpty {
|
||||||
|
let peerCount = delegate?.getConnectedPeers().count ?? 0
|
||||||
|
SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
|||||||
@@ -36,29 +36,29 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case .fragment: return 5
|
case .fragment: return 5
|
||||||
case .requestSync: return 6
|
case .requestSync: return 6
|
||||||
case .fileTransfer: return 7
|
case .fileTransfer: return 7
|
||||||
case .boardPost: return 8
|
|
||||||
// Extended bits are compat-safe by construction: `toData()` encodes
|
// Extended bits are compat-safe by construction: `toData()` encodes
|
||||||
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
||||||
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
||||||
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
||||||
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
||||||
// ignore the group bit and answer with the types they know.
|
// ignore the group bit and answer with the types they know.
|
||||||
|
case .boardPost: return 8
|
||||||
case .groupMessage: return 10
|
case .groupMessage: return 10
|
||||||
// Courier envelopes are directed deposits between trusted peers and
|
// Courier envelopes are directed deposits between trusted peers and
|
||||||
// must never spread via gossip sync.
|
// must never spread via gossip sync.
|
||||||
case .courierEnvelope: return nil
|
case .courierEnvelope: return nil
|
||||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
// The bitfield is a wire-tolerant little-endian UInt64 (1-8 bytes,
|
||||||
// sync would only produce stale, unanswerable echoes.
|
// unknown high bits ignored by `type(forBit:)`), so bits 8+ need no
|
||||||
case .ping, .pong: return nil
|
// format change: old clients decode the wider flags and simply never
|
||||||
|
// match the new bits.
|
||||||
|
case .prekeyBundle: return 9
|
||||||
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||||
// would waste airtime and extend their lifetime.
|
// would waste airtime and extend their lifetime.
|
||||||
case .nostrCarrier: return nil
|
case .nostrCarrier: return nil
|
||||||
// Prekey bundles gossip like board posts. The bitfield is a
|
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||||
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
|
// sync would only produce stale, unanswerable echoes.
|
||||||
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
|
case .ping, .pong: return nil
|
||||||
// clients decode the wider flags and simply never match the new bits.
|
|
||||||
case .prekeyBundle: return 9
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +115,6 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact form for logs, e.g. "message+fragment". Without this, the
|
|
||||||
/// per-schedule periodic sync rounds log identical lines and read as
|
|
||||||
/// duplicated sends (misdiagnosed twice during July 2026 device testing).
|
|
||||||
var logDescription: String {
|
|
||||||
let types = toMessageTypes()
|
|
||||||
guard !types.isEmpty else { return "none" }
|
|
||||||
return types.map { String(describing: $0) }.joined(separator: "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMessageTypes() -> [MessageType] {
|
func toMessageTypes() -> [MessageType] {
|
||||||
guard rawValue != 0 else { return [] }
|
guard rawValue != 0 else { return [] }
|
||||||
var types: [MessageType] = []
|
var types: [MessageType] = []
|
||||||
@@ -159,4 +150,19 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
}
|
}
|
||||||
return SyncTypeFlags(rawValue: raw)
|
return SyncTypeFlags(rawValue: raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre".
|
||||||
|
/// (Referenced only from DEBUG log lines, but kept unconditional so the
|
||||||
|
/// log-call arguments compile in release too, where the log itself no-ops.)
|
||||||
|
var debugShortLabel: String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if contains(.announce) { parts.append("ann") }
|
||||||
|
if contains(.message) { parts.append("msg") }
|
||||||
|
if contains(.fragment) { parts.append("frag") }
|
||||||
|
if contains(.fileTransfer) { parts.append("file") }
|
||||||
|
if contains(.prekeyBundle) { parts.append("pre") }
|
||||||
|
if contains(.groupMessage) { parts.append("grp") }
|
||||||
|
if contains(.boardPost) { parts.append("board") }
|
||||||
|
return parts.isEmpty ? "none" : parts.joined(separator: ",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,29 +227,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
final class ChatPrivateConversationCoordinator {
|
final class ChatPrivateConversationCoordinator {
|
||||||
private unowned let context: any ChatPrivateConversationContext
|
private unowned let context: any ChatPrivateConversationContext
|
||||||
|
|
||||||
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
|
|
||||||
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
|
||||||
// message IDs so each copy past the first costs one (already-deduped)
|
|
||||||
// ack check and nothing else.
|
|
||||||
private var seenInboundGeoDMIDs: Set<String> = []
|
|
||||||
private var seenInboundGeoDMOrder: [String] = []
|
|
||||||
private static let seenInboundGeoDMCap = 512
|
|
||||||
|
|
||||||
init(context: any ChatPrivateConversationContext) {
|
init(context: any ChatPrivateConversationContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `false` if this GeoDM message ID was already handled.
|
|
||||||
private func markInboundGeoDMSeen(_ messageId: String) -> Bool {
|
|
||||||
guard !seenInboundGeoDMIDs.contains(messageId) else { return false }
|
|
||||||
seenInboundGeoDMIDs.insert(messageId)
|
|
||||||
seenInboundGeoDMOrder.append(messageId)
|
|
||||||
if seenInboundGeoDMOrder.count > Self.seenInboundGeoDMCap {
|
|
||||||
seenInboundGeoDMIDs.remove(seenInboundGeoDMOrder.removeFirst())
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
@@ -427,15 +408,10 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||||
let messageId = pm.messageID
|
let messageId = pm.messageID
|
||||||
|
|
||||||
// Ack before the dedup guard: a re-sent copy means the sender may not
|
|
||||||
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
|
|
||||||
// actual sends.
|
|
||||||
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
|
||||||
|
|
||||||
guard markInboundGeoDMSeen(messageId) else { return }
|
|
||||||
|
|
||||||
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
||||||
|
|
||||||
|
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||||
|
|
||||||
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -514,10 +490,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// A stale ack for a message this device no longer tracks (dropped
|
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||||
// outbox entry, cleared chat, or a peer re-acking after losing our
|
|
||||||
// receipt) — expected occasionally, not actionable.
|
|
||||||
SecureLogger.debug("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
// MARK: Verification payloads
|
// MARK: Verification payloads
|
||||||
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
|
||||||
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
|
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
|
||||||
|
func handleVouchPayload(from peerID: PeerID, payload: Data)
|
||||||
|
|
||||||
// MARK: Group payloads (creator-signed state over Noise)
|
// MARK: Group payloads (creator-signed state over Noise)
|
||||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
|
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
|
||||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
|
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
|
||||||
func handleVouchPayload(from peerID: PeerID, payload: Data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatTransportEventContext {
|
extension ChatViewModel: ChatTransportEventContext {
|
||||||
@@ -135,6 +135,10 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
|
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||||
|
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
|
||||||
|
}
|
||||||
|
|
||||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
|
||||||
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
|
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||||
}
|
}
|
||||||
@@ -142,10 +146,6 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||||
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
|
||||||
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ChatTransportEventCoordinator {
|
final class ChatTransportEventCoordinator {
|
||||||
@@ -389,14 +389,19 @@ private extension ChatTransportEventCoordinator {
|
|||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
|
case .vouch:
|
||||||
|
context.handleVouchPayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
case .groupInvite:
|
case .groupInvite:
|
||||||
context.handleGroupInvitePayload(from: peerID, payload: payload)
|
context.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
case .groupKeyUpdate:
|
case .groupKeyUpdate:
|
||||||
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
case .vouch:
|
case .bulkTransferOffer, .bulkTransferResponse:
|
||||||
context.handleVouchPayload(from: peerID, payload: payload)
|
// Wi-Fi bulk negotiation is consumed inside the mesh transport
|
||||||
|
// (BLEService); it never reaches the UI layer.
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ protocol ChatVerificationContext: AnyObject {
|
|||||||
func setStoredVerified(_ fingerprint: String, verified: Bool)
|
func setStoredVerified(_ fingerprint: String, verified: Bool)
|
||||||
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
|
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
|
||||||
func saveIdentityState()
|
func saveIdentityState()
|
||||||
/// After a fingerprint becomes verified, run a transitive-vouch pass over
|
|
||||||
/// currently connected peers (so verifying a peer you're already connected
|
|
||||||
/// to sends vouches immediately, and the new identity propagates onward).
|
|
||||||
func vouchToConnectedVerifiedPeers()
|
|
||||||
|
|
||||||
// MARK: Encryption status
|
// MARK: Encryption status
|
||||||
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
|
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
|
||||||
@@ -90,10 +86,6 @@ extension ChatViewModel: ChatVerificationContext {
|
|||||||
peerIdentityStore.setVerified(fingerprint, verified: verified)
|
peerIdentityStore.setVerified(fingerprint, verified: verified)
|
||||||
}
|
}
|
||||||
|
|
||||||
func vouchToConnectedVerifiedPeers() {
|
|
||||||
vouchCoordinator.vouchToConnectedVerifiedPeers()
|
|
||||||
}
|
|
||||||
|
|
||||||
var unifiedPeers: [BitchatPeer] {
|
var unifiedPeers: [BitchatPeer] {
|
||||||
unifiedPeerService.peers
|
unifiedPeerService.peers
|
||||||
}
|
}
|
||||||
@@ -156,9 +148,6 @@ final class ChatVerificationCoordinator {
|
|||||||
context.saveIdentityState()
|
context.saveIdentityState()
|
||||||
context.setStoredVerified(fingerprint, verified: true)
|
context.setStoredVerified(fingerprint, verified: true)
|
||||||
context.updateEncryptionStatus(for: peerID)
|
context.updateEncryptionStatus(for: peerID)
|
||||||
// Verifying a peer is a vouch trigger: push attestations to my other
|
|
||||||
// connected verified peers (and to this one if already connected).
|
|
||||||
context.vouchToConnectedVerifiedPeers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func unverifyFingerprint(for peerID: PeerID) {
|
func unverifyFingerprint(for peerID: PeerID) {
|
||||||
@@ -351,8 +340,6 @@ final class ChatVerificationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.updateEncryptionStatus(for: peerID)
|
context.updateEncryptionStatus(for: peerID)
|
||||||
// QR verification just completed — same vouch trigger as manual verify.
|
|
||||||
context.vouchToConnectedVerifiedPeers()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
|
|
||||||
// Computed properties for compatibility
|
// Computed properties for compatibility
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -1215,12 +1215,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
GossipMessageArchive.wipeDefault()
|
GossipMessageArchive.wipeDefault()
|
||||||
StoreAndForwardMetrics.shared.reset()
|
StoreAndForwardMetrics.shared.reset()
|
||||||
|
|
||||||
// Drop private group keys and rosters (keychain + disk)
|
|
||||||
groupStore.wipe()
|
|
||||||
// Drop cached peers' prekey bundles (who we could write to is
|
// Drop cached peers' prekey bundles (who we could write to is
|
||||||
// metadata too). Our own prekey privates are keychain-backed and go
|
// metadata too). Our own prekey privates are keychain-backed and go
|
||||||
// with deleteAllKeychainData above plus the identity reset below.
|
// with deleteAllKeychainData above plus the identity reset below.
|
||||||
PrekeyBundleStore.shared.wipe()
|
PrekeyBundleStore.shared.wipe()
|
||||||
|
|
||||||
|
// Drop private group keys and rosters (keychain + disk)
|
||||||
|
groupStore.wipe()
|
||||||
|
|
||||||
// Drop bulletin-board posts and tombstones (memory and disk); board
|
// Drop bulletin-board posts and tombstones (memory and disk); board
|
||||||
// posts are signed with our identity key and persist for days.
|
// posts are signed with our identity key and persist for days.
|
||||||
BoardStore.shared.wipe()
|
BoardStore.shared.wipe()
|
||||||
@@ -1293,12 +1295,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Delete ALL media files (incoming and outgoing) in background
|
// Delete ALL media files (incoming and outgoing) in background
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
// Skipped under tests: the test process shares the user's real
|
// The SPM test process shares the real Application Support tree, so
|
||||||
// ~/Library/Application Support/files tree, and this detached
|
// this detached tree-delete can land mid-test under parallel
|
||||||
// utility-priority wipe fires at a nondeterministic time —
|
// scheduling and flake a file-dependent test; tests never need the
|
||||||
// deleting media that concurrently running tests (e.g. the
|
// on-disk media wiped.
|
||||||
// sendImage flow) just wrote there, and the developer's real
|
|
||||||
// app data with it.
|
|
||||||
guard !TestEnvironment.isRunningTests else { return }
|
guard !TestEnvironment.isRunningTests else { return }
|
||||||
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)
|
||||||
@@ -1650,12 +1650,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||||
peerListCoordinator.didUpdatePeerList(peers)
|
peerListCoordinator.didUpdatePeerList(peers)
|
||||||
// A peer-list update follows every verified announce, which is where a
|
|
||||||
// peer's `.vouch` capability actually arrives — retry vouching now that
|
|
||||||
// capabilities may finally be known (closes the auth-time capability race).
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.vouchCoordinator.peersUpdated(peers)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -1744,19 +1738,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
func addGeohashOnlySystemMessage(_ content: String) {
|
func addGeohashOnlySystemMessage(_ content: String) {
|
||||||
publicConversationCoordinator.addGeohashOnlySystemMessage(content)
|
publicConversationCoordinator.addGeohashOnlySystemMessage(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a local system message to one specific geohash timeline, active or
|
|
||||||
/// not. Used by the board's new-pin alerts to scope-match the pin's channel.
|
|
||||||
@MainActor
|
|
||||||
func addGeohashSystemMessage(_ content: String, geohash: String) {
|
|
||||||
let systemMessage = BitchatMessage(
|
|
||||||
sender: "system",
|
|
||||||
content: content,
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash)
|
|
||||||
}
|
|
||||||
// Send a public message without adding a local user echo.
|
// Send a public message without adding a local user echo.
|
||||||
// Used for emotes where we want a local system-style confirmation instead.
|
// Used for emotes where we want a local system-style confirmation instead.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -41,11 +41,7 @@ struct ChatViewModelServiceBundle {
|
|||||||
self.privateChatManager = privateChatManager
|
self.privateChatManager = privateChatManager
|
||||||
self.unifiedPeerService = unifiedPeerService
|
self.unifiedPeerService = unifiedPeerService
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
self.deduplicationService = MessageDeduplicationService()
|
||||||
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
|
||||||
// events on every launch and only a cross-launch record stops the
|
|
||||||
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
|
||||||
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
|
||||||
self.publicMessagePipeline = PublicMessagePipeline()
|
self.publicMessagePipeline = PublicMessagePipeline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ protocol ChatVouchContext: AnyObject {
|
|||||||
|
|
||||||
// MARK: Transport
|
// MARK: Transport
|
||||||
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
|
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
|
||||||
/// PeerIDs with a currently established mesh session (used to run a vouch
|
|
||||||
/// pass over peers we are already connected to when we verify someone).
|
|
||||||
func connectedPeerIDs() -> [PeerID]
|
|
||||||
/// Appends a session-established observer (additive; never displaces the
|
/// Appends a session-established observer (additive; never displaces the
|
||||||
/// verification coordinator's callbacks).
|
/// verification coordinator's callbacks).
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
|
||||||
@@ -77,10 +74,6 @@ extension ChatViewModel: ChatVouchContext {
|
|||||||
meshService.peerCapabilities(peerID)
|
meshService.peerCapabilities(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectedPeerIDs() -> [PeerID] {
|
|
||||||
Array(connectedPeers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
meshService.addPeerAuthenticatedObserver(handler)
|
meshService.addPeerAuthenticatedObserver(handler)
|
||||||
}
|
}
|
||||||
@@ -127,70 +120,16 @@ final class ChatVouchCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger — session established: on a Noise session coming up with a peer
|
/// Exchange policy: on session establishment with a peer I verified that
|
||||||
/// I verified, attempt to send a vouch batch. Kept as the historical entry
|
/// advertises the `.vouch` capability, send attestations for up to
|
||||||
/// point; the real work lives in `attemptVouch`.
|
/// `VouchAttestation.maxBatchCount` *other* verified fingerprints (most
|
||||||
|
/// recently verified first), at most once per peer per `batchInterval`.
|
||||||
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
|
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
|
||||||
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
|
guard context.isVerifiedFingerprint(fingerprint) else { return }
|
||||||
}
|
guard context.peerCapabilities(for: peerID).contains(.vouch) else { return }
|
||||||
|
|
||||||
/// Trigger — verified announce processed: a peer's `.vouch` capability
|
|
||||||
/// arrives on its *announce*, which is handled independently of the Noise
|
|
||||||
/// handshake. This is invoked on every peer-list update (fired after each
|
|
||||||
/// verified announce), so it closes the capability race — the batch that
|
|
||||||
/// `peerAuthenticated` couldn't send (capabilities not yet known) goes out
|
|
||||||
/// once the capability-bearing announce lands. Throttled per peer.
|
|
||||||
func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) {
|
|
||||||
for peerID in peerIDs {
|
|
||||||
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
|
|
||||||
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trigger — local verification completed: the user just verified a peer.
|
|
||||||
/// Run a vouch pass over every currently connected peer I verified. This
|
|
||||||
/// makes vouching fire when verifying someone already connected (whose
|
|
||||||
/// session is authenticated, so `peerAuthenticated` never re-fires), and it
|
|
||||||
/// propagates the newly-verified identity to my other verified peers.
|
|
||||||
/// Throttled per peer by `batchInterval`, so it can't spam.
|
|
||||||
func vouchToConnectedVerifiedPeers(now: Date = Date()) {
|
|
||||||
var sentCount = 0
|
|
||||||
for peerID in context.connectedPeerIDs() {
|
|
||||||
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
|
|
||||||
if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) {
|
|
||||||
sentCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sentCount > 0 {
|
|
||||||
SecureLogger.info(
|
|
||||||
"🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)",
|
|
||||||
category: .security
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exchange policy shared by every trigger: to a peer I verified, send
|
|
||||||
/// attestations for up to `VouchAttestation.maxBatchCount` *other* verified
|
|
||||||
/// fingerprints (most recently verified first), at most once per peer per
|
|
||||||
/// `batchInterval`. Returns whether a batch was actually sent.
|
|
||||||
@discardableResult
|
|
||||||
func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool {
|
|
||||||
guard context.isVerifiedFingerprint(fingerprint) else { return false }
|
|
||||||
|
|
||||||
// Capability gate, race-tolerant: a peer's `.vouch` bit is carried on
|
|
||||||
// its announce, processed independently of the Noise handshake, so at
|
|
||||||
// authentication time the capability set is frequently still empty.
|
|
||||||
// Treat an empty/unknown set as eligible — the payload is a Noise
|
|
||||||
// `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly
|
|
||||||
// ignore, so sending on an unknown set is safe and avoids the race
|
|
||||||
// dropping the batch. Only skip when the peer advertised a non-empty
|
|
||||||
// capability set that explicitly lacks `.vouch`.
|
|
||||||
let capabilities = context.peerCapabilities(for: peerID)
|
|
||||||
if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false }
|
|
||||||
|
|
||||||
if let lastSent = context.lastVouchBatchSent(to: fingerprint),
|
if let lastSent = context.lastVouchBatchSent(to: fingerprint),
|
||||||
now.timeIntervalSince(lastSent) < Self.batchInterval {
|
now.timeIntervalSince(lastSent) < Self.batchInterval {
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = context.recentlyVerifiedFingerprints(
|
let candidates = context.recentlyVerifiedFingerprints(
|
||||||
@@ -217,14 +156,13 @@ final class ChatVouchCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !attestations.isEmpty,
|
guard !attestations.isEmpty,
|
||||||
let payload = VouchAttestation.encodeList(attestations) else { return false }
|
let payload = VouchAttestation.encodeList(attestations) else { return }
|
||||||
context.sendVouchAttestations(payload, to: peerID)
|
context.sendVouchAttestations(payload, to: peerID)
|
||||||
context.markVouchBatchSent(to: fingerprint, at: now)
|
context.markVouchBatchSent(to: fingerprint, at: now)
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…",
|
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…",
|
||||||
category: .security
|
category: .security
|
||||||
)
|
)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept policy: process inbound vouches only from a sender I verified,
|
/// Accept policy: process inbound vouches only from a sender I verified,
|
||||||
|
|||||||
@@ -304,8 +304,10 @@ final class NostrInboundPipeline {
|
|||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
// Group state travels only over mesh Noise sessions in v1; anything
|
// Group state travels only over mesh Noise sessions in v1; anything
|
||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
// negotiation is mesh-proximity only; it never rides Nostr either.
|
||||||
|
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
|
||||||
|
.bulkTransferOffer, .bulkTransferResponse:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,8 +360,10 @@ final class NostrInboundPipeline {
|
|||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
// Group state travels only over mesh Noise sessions in v1; anything
|
// Group state travels only over mesh Noise sessions in v1; anything
|
||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
// negotiation is mesh-proximity only; it never rides Nostr either.
|
||||||
|
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
|
||||||
|
.bulkTransferOffer, .bulkTransferResponse:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,8 +443,11 @@ final class NostrInboundPipeline {
|
|||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
// Group state travels only over mesh Noise sessions
|
// Group state travels only over mesh Noise sessions
|
||||||
// in v1; group traffic over Nostr is ignored.
|
// in v1; group traffic over Nostr is ignored. Wi-Fi
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
// bulk negotiation is mesh-proximity only; it never
|
||||||
|
// rides Nostr either.
|
||||||
|
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
|
||||||
|
.bulkTransferOffer, .bulkTransferResponse:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-21
@@ -1,4 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if DEBUG
|
||||||
|
import BitLogger
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
struct AppInfoView: View {
|
struct AppInfoView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -10,6 +18,14 @@ struct AppInfoView: View {
|
|||||||
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
|
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
|
||||||
@State private var showTopology = false
|
@State private var showTopology = false
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off.
|
||||||
|
@AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = ""
|
||||||
|
@AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort
|
||||||
|
@State private var exportedLogURL: URL?
|
||||||
|
@State private var isPresentingLogShare = false
|
||||||
|
#endif
|
||||||
|
|
||||||
private var selectedTheme: AppTheme {
|
private var selectedTheme: AppTheme {
|
||||||
AppTheme(rawValue: appThemeRawValue) ?? .matrix
|
AppTheme(rawValue: appThemeRawValue) ?? .matrix
|
||||||
}
|
}
|
||||||
@@ -223,27 +239,6 @@ struct AppInfoView: View {
|
|||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network diagnostics
|
|
||||||
if topologyProvider != nil {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
SectionHeader(Strings.Network.title)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showTopology = true
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
FeatureRow(info: Strings.Network.topology)
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.bitchatSystem(size: 12))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityHint(Text("app_info.network.topology.hint"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Features.title)
|
SectionHeader(Strings.Features.title)
|
||||||
@@ -283,6 +278,27 @@ struct AppInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Network diagnostics
|
||||||
|
if topologyProvider != nil {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
SectionHeader(Strings.Network.title)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showTopology = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
FeatureRow(info: Strings.Network.topology)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.bitchatSystem(size: 12))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(Text("app_info.network.topology.hint"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Privacy
|
// Privacy
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Privacy.title)
|
SectionHeader(Strings.Privacy.title)
|
||||||
@@ -293,11 +309,122 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
FeatureRow(info: Strings.Privacy.panic)
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
debugSection
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Tester-only observability panel: live LAN log streaming + a logs export.
|
||||||
|
// Never compiled into release (privacy-first: release ships no such UI).
|
||||||
|
@ViewBuilder
|
||||||
|
private var debugSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader("debug & logs")
|
||||||
|
|
||||||
|
Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.")
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.bitchatFont(size: 13)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
#endif
|
||||||
|
.accessibilityLabel(Text("log collector host"))
|
||||||
|
|
||||||
|
TextField("port", value: $logSinkPort, format: .number.grouping(.never))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.bitchatFont(size: 13)
|
||||||
|
.frame(width: 70)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
#endif
|
||||||
|
.accessibilityLabel(Text("log collector port"))
|
||||||
|
}
|
||||||
|
.onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() }
|
||||||
|
.onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() }
|
||||||
|
|
||||||
|
Button(action: exportLogs) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.bitchatSystem(size: 20))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.frame(width: 30)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Export Logs")
|
||||||
|
.bitchatFont(size: 14, weight: .semibold)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
Text("Share the recent in-memory log buffer as a .txt file.")
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(Text("Export logs"))
|
||||||
|
.accessibilityHint(Text("Shares the recent log buffer as a text file"))
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $isPresentingLogShare) {
|
||||||
|
if let exportedLogURL {
|
||||||
|
LogShareSheet(activityItems: [exportedLogURL])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the buffered log to a temp `.txt` and present the platform share.
|
||||||
|
private func exportLogs() {
|
||||||
|
let text = LogExportBuffer.shared.snapshot()
|
||||||
|
let stamp = ISO8601DateFormatter().string(from: Date())
|
||||||
|
.replacingOccurrences(of: ":", with: "-")
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bitchat-logs-\(stamp).txt")
|
||||||
|
do {
|
||||||
|
try text.data(using: .utf8)?.write(to: url)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
exportedLogURL = url
|
||||||
|
isPresentingLogShare = true
|
||||||
|
#elseif os(macOS)
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = url.lastPathComponent
|
||||||
|
panel.allowedContentTypes = [.plainText]
|
||||||
|
if panel.runModal() == .OK, let dest = panel.url {
|
||||||
|
try? text.data(using: .utf8)?.write(to: dest)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG && os(iOS)
|
||||||
|
/// Minimal UIActivityViewController bridge for the Export Logs share sheet.
|
||||||
|
private struct LogShareSheet: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
struct AppInfoFeatureInfo {
|
struct AppInfoFeatureInfo {
|
||||||
let icon: String
|
let icon: String
|
||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//
|
||||||
|
// BoardView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The bulletin board for one context: a geohash channel, or the mesh-local
|
||||||
|
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
|
||||||
|
/// be swipe-deleted, which broadcasts a signed tombstone.
|
||||||
|
struct BoardView: View {
|
||||||
|
/// Empty string = mesh-local board.
|
||||||
|
let geohash: String
|
||||||
|
let senderNickname: String
|
||||||
|
@ObservedObject var board: BoardManager
|
||||||
|
|
||||||
|
@ThemedPalette private var palette
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var draft: String = ""
|
||||||
|
@State private var urgent = false
|
||||||
|
@State private var expiryDays = 7
|
||||||
|
|
||||||
|
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||||
|
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
|
||||||
|
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
|
||||||
|
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
||||||
|
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
||||||
|
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
||||||
|
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
||||||
|
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
||||||
|
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
||||||
|
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
||||||
|
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
||||||
|
static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
|
||||||
|
|
||||||
|
static func expiryDaysOption(_ days: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
||||||
|
locale: .current,
|
||||||
|
days
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
||||||
|
let base = String(
|
||||||
|
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
||||||
|
locale: .current,
|
||||||
|
author, content
|
||||||
|
)
|
||||||
|
return urgent ? "\(urgentBadge), \(base)" : base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
postList
|
||||||
|
composer
|
||||||
|
}
|
||||||
|
.themedSurface()
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||||
|
#endif
|
||||||
|
.themedSheetBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
|
||||||
|
.bitchatFont(size: 18)
|
||||||
|
Spacer()
|
||||||
|
SheetCloseButton { dismiss() }
|
||||||
|
.accessibilityLabel(Strings.closeHint)
|
||||||
|
}
|
||||||
|
Text(Strings.description)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.themedSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var postList: some View {
|
||||||
|
Group {
|
||||||
|
if posts.isEmpty {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.emptyTitle)
|
||||||
|
.bitchatFont(size: 13, weight: .semibold)
|
||||||
|
Text(Strings.emptySubtitle)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(posts, id: \.postID) { post in
|
||||||
|
postRow(post)
|
||||||
|
.listRowBackground(palette.background)
|
||||||
|
.listRowSeparatorTint(palette.divider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.themedSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postRow(_ post: BoardPostPacket) -> some View {
|
||||||
|
let isOwn = board.isOwnPost(post)
|
||||||
|
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if post.isUrgent {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.bitchatSystem(size: 11))
|
||||||
|
.foregroundColor(palette.alertRed)
|
||||||
|
Text(Strings.urgentBadge)
|
||||||
|
.bitchatFont(size: 11, weight: .semibold)
|
||||||
|
.foregroundColor(palette.alertRed)
|
||||||
|
}
|
||||||
|
Text(verbatim: "@\(author)")
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
Text(timestampText(forMs: post.createdAt))
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
Spacer()
|
||||||
|
if isOwn {
|
||||||
|
Button {
|
||||||
|
board.deletePost(post)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.bitchatSystem(size: 12))
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(Strings.deleteAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(post.content)
|
||||||
|
.bitchatFont(size: 14)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
|
||||||
|
.accessibilityActions {
|
||||||
|
if isOwn {
|
||||||
|
Button(Strings.deleteAction) { board.deletePost(post) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
if isOwn {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
board.deletePost(post)
|
||||||
|
} label: {
|
||||||
|
Label(Strings.deleteAction, systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.bitchatFont(size: 14)
|
||||||
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
Button(action: send) {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.bitchatSystem(size: 20))
|
||||||
|
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!sendEnabled)
|
||||||
|
.accessibilityLabel(Strings.send)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Toggle(isOn: $urgent) {
|
||||||
|
Text(Strings.urgentToggle)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.fixedSize()
|
||||||
|
.accessibilityLabel(Strings.urgentToggle)
|
||||||
|
Spacer()
|
||||||
|
Text(Strings.expiryLabel)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
||||||
|
ForEach([1, 3, 7], id: \.self) { days in
|
||||||
|
Text(Strings.expiryDaysOption(days)).tag(days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.fixedSize()
|
||||||
|
.accessibilityLabel(Strings.expiryLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.themedSurface()
|
||||||
|
.overlay(Divider(), alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sendEnabled: Bool {
|
||||||
|
let trimmed = draft.trimmed
|
||||||
|
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send() {
|
||||||
|
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||||
|
let sent = board.createPost(
|
||||||
|
content: content,
|
||||||
|
geohash: geohash,
|
||||||
|
urgent: urgent,
|
||||||
|
expiryDays: expiryDays,
|
||||||
|
nickname: senderNickname
|
||||||
|
)
|
||||||
|
if sent {
|
||||||
|
draft = ""
|
||||||
|
urgent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timestampText(forMs ms: UInt64) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||||
|
let now = Date()
|
||||||
|
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||||
|
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||||
|
return rel.isEmpty ? "" : "\(rel) ago"
|
||||||
|
}
|
||||||
|
return Self.absDateFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let relativeFormatter: DateComponentsFormatter = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.allowedUnits = [.day, .hour, .minute]
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .abbreviated
|
||||||
|
f.collapsesLargestUnit = true
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let absDateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ContentHeaderView: View {
|
struct ContentHeaderView: View {
|
||||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||||
@EnvironmentObject private var verificationModel: VerificationModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
@EnvironmentObject private var peerListModel: PeerListModel
|
@EnvironmentObject private var peerListModel: PeerListModel
|
||||||
@EnvironmentObject private var boardAlertsModel: BoardAlertsModel
|
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@Environment(\.appTheme) private var theme
|
@Environment(\.appTheme) private var theme
|
||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
@Binding var showSidebar: Bool
|
@Binding var showSidebar: Bool
|
||||||
@Binding var showVerifySheet: Bool
|
@Binding var showVerifySheet: Bool
|
||||||
|
@Binding var showLocationNotes: Bool
|
||||||
|
@Binding var notesGeohash: String?
|
||||||
var isNicknameFieldFocused: FocusState<Bool>.Binding
|
var isNicknameFieldFocused: FocusState<Bool>.Binding
|
||||||
|
|
||||||
let headerHeight: CGFloat
|
let headerHeight: CGFloat
|
||||||
@@ -21,13 +25,8 @@ struct ContentHeaderView: View {
|
|||||||
/// Courier envelopes this device is carrying for offline third parties.
|
/// Courier envelopes this device is carrying for offline third parties.
|
||||||
@State private var carriedMailCount = 0
|
@State private var carriedMailCount = 0
|
||||||
|
|
||||||
/// Unified notices sheet (board posts + location notes) for the current
|
/// Bulletin board sheet for the current channel context.
|
||||||
/// channel context.
|
@State private var showBoard = false
|
||||||
@State private var showNotices = false
|
|
||||||
|
|
||||||
/// Board posts mirrored from the store so the pin icon can show when the
|
|
||||||
/// current scope has notices.
|
|
||||||
@State private var boardPosts: [BoardPostPacket] = []
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -138,40 +137,36 @@ struct ContentHeaderView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
if case .mesh = locationChannelsModel.selectedChannel,
|
||||||
var scopes: Set<String> = [""]
|
locationChannelsModel.permissionState == .authorized {
|
||||||
if let geoScope = noticesGeoScope {
|
Button(action: {
|
||||||
scopes.insert(geoScope)
|
locationChannelsModel.enableAndRefresh()
|
||||||
|
notesGeohash = locationChannelsModel.currentBuildingGeohash
|
||||||
|
showLocationNotes = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "note.text")
|
||||||
|
.font(.bitchatSystem(size: 12))
|
||||||
|
.foregroundColor(Color.orange.opacity(0.8))
|
||||||
|
.headerTapTarget()
|
||||||
}
|
}
|
||||||
boardAlertsModel.markSeen(forScopes: scopes)
|
.buttonStyle(.plain)
|
||||||
showNotices = true
|
.accessibilityLabel(
|
||||||
}) {
|
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
||||||
// Fill marks unseen new pins; the tint says the current
|
)
|
||||||
// scope has notices at all.
|
}
|
||||||
Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin")
|
|
||||||
|
Button(action: { showBoard = true }) {
|
||||||
|
Image(systemName: "pin")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(
|
.foregroundColor(palette.secondary.opacity(0.9))
|
||||||
scopeHasNotices || unseenNoticesCount > 0
|
|
||||||
? Color.orange.opacity(0.8)
|
|
||||||
: palette.secondary.opacity(0.9)
|
|
||||||
)
|
|
||||||
.headerTapTarget()
|
.headerTapTarget()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button")
|
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
|
||||||
)
|
|
||||||
.accessibilityValue(
|
|
||||||
unseenNoticesCount > 0
|
|
||||||
? String(
|
|
||||||
format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"),
|
|
||||||
locale: .current,
|
|
||||||
unseenNoticesCount
|
|
||||||
)
|
|
||||||
: ""
|
|
||||||
)
|
)
|
||||||
.help(
|
.help(
|
||||||
String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button")
|
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
|
||||||
)
|
)
|
||||||
|
|
||||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
@@ -272,21 +267,54 @@ struct ContentHeaderView: View {
|
|||||||
.onReceive(CourierStore.shared.$carriedCount) { count in
|
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||||
carriedMailCount = count
|
carriedMailCount = count
|
||||||
}
|
}
|
||||||
.onReceive(BoardStore.shared.$postsSnapshot) { posts in
|
|
||||||
boardPosts = posts
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||||
.environmentObject(locationChannelsModel)
|
.environmentObject(locationChannelsModel)
|
||||||
.environmentObject(peerListModel)
|
.environmentObject(peerListModel)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNotices) {
|
.sheet(isPresented: $showBoard) {
|
||||||
NoticesView(
|
BoardView(
|
||||||
|
geohash: boardGeohash,
|
||||||
senderNickname: appChromeModel.nickname,
|
senderNickname: appChromeModel.nickname,
|
||||||
board: appChromeModel.boardManager,
|
board: appChromeModel.boardManager
|
||||||
initialTab: initialNoticesTab
|
|
||||||
)
|
)
|
||||||
.environmentObject(locationChannelsModel)
|
}
|
||||||
|
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||||
|
notesGeohash = nil
|
||||||
|
}) {
|
||||||
|
Group {
|
||||||
|
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
|
||||||
|
LocationNotesView(
|
||||||
|
geohash: geohash,
|
||||||
|
senderNickname: appChromeModel.nickname
|
||||||
|
)
|
||||||
|
.environmentObject(locationChannelsModel)
|
||||||
|
} else {
|
||||||
|
ContentLocationNotesUnavailableView(
|
||||||
|
showLocationNotes: $showLocationNotes,
|
||||||
|
headerHeight: headerHeight
|
||||||
|
)
|
||||||
|
.environmentObject(locationChannelsModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
locationChannelsModel.enableLocationChannels()
|
||||||
|
locationChannelsModel.beginLiveRefresh()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
locationChannelsModel.endLiveRefresh()
|
||||||
|
}
|
||||||
|
.onChange(of: locationChannelsModel.availableChannels) { channels in
|
||||||
|
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
||||||
|
notesGeohash != current {
|
||||||
|
notesGeohash = current
|
||||||
|
#if os(iOS)
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
generator.prepare()
|
||||||
|
generator.impactOccurred()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||||
@@ -320,34 +348,13 @@ private extension ContentHeaderView {
|
|||||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the notices sheet on the tab matching the current channel: the
|
/// The board scope for the current channel: the geohash channel's board,
|
||||||
/// geohash channel's notices, or the mesh-local board in mesh chat.
|
/// or the mesh-local board ("") in mesh chat.
|
||||||
var initialNoticesTab: NoticesView.Tab {
|
var boardGeohash: String {
|
||||||
if case .location = locationChannelsModel.selectedChannel {
|
|
||||||
return .geo
|
|
||||||
}
|
|
||||||
return .mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The geo scope the notices sheet would open on: the selected location
|
|
||||||
/// channel, or the device's building geohash when chatting on mesh.
|
|
||||||
var noticesGeoScope: String? {
|
|
||||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
return channel.geohash
|
return channel.geohash
|
||||||
}
|
}
|
||||||
return locationChannelsModel.currentBuildingGeohash
|
return ""
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether either tab of the notices sheet currently has content.
|
|
||||||
var scopeHasNotices: Bool {
|
|
||||||
boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// New pins in either visible scope since the sheet was last opened.
|
|
||||||
var unseenNoticesCount: Int {
|
|
||||||
let meshCount = boardAlertsModel.unseenCount(forGeohash: "")
|
|
||||||
let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0
|
|
||||||
return meshCount + geoCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether anyone is actually reachable on the current channel — the
|
/// Whether anyone is actually reachable on the current channel — the
|
||||||
@@ -373,3 +380,37 @@ private extension ContentHeaderView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ContentLocationNotesUnavailableView: View {
|
||||||
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
|
@Binding var showLocationNotes: Bool
|
||||||
|
|
||||||
|
let headerHeight: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("content.notes.title")
|
||||||
|
.bitchatFont(size: 16, weight: .bold)
|
||||||
|
Spacer()
|
||||||
|
SheetCloseButton { showLocationNotes = false }
|
||||||
|
.foregroundColor(palette.primary)
|
||||||
|
}
|
||||||
|
.frame(minHeight: headerHeight)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.themedChromePanel(edge: .top)
|
||||||
|
Text("content.notes.location_unavailable")
|
||||||
|
.bitchatFont(size: 14)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
Button("content.location.enable") {
|
||||||
|
locationChannelsModel.enableAndRefresh()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.themedSheetBackground()
|
||||||
|
.foregroundColor(palette.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ struct ContentView: View {
|
|||||||
@State private var isAtBottomPrivate = true
|
@State private var isAtBottomPrivate = true
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showVerifySheet = false
|
@State private var showVerifySheet = false
|
||||||
|
@State private var showLocationNotes = false
|
||||||
|
@State private var notesGeohash: String?
|
||||||
@State private var imagePreviewURL: URL?
|
@State private var imagePreviewURL: URL?
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var showImagePicker = false
|
@State private var showImagePicker = false
|
||||||
@@ -267,6 +269,8 @@ struct ContentView: View {
|
|||||||
ContentHeaderView(
|
ContentHeaderView(
|
||||||
showSidebar: $showSidebar,
|
showSidebar: $showSidebar,
|
||||||
showVerifySheet: $showVerifySheet,
|
showVerifySheet: $showVerifySheet,
|
||||||
|
showLocationNotes: $showLocationNotes,
|
||||||
|
notesGeohash: $notesGeohash,
|
||||||
isNicknameFieldFocused: $isNicknameFieldFocused,
|
isNicknameFieldFocused: $isNicknameFieldFocused,
|
||||||
headerHeight: headerHeight,
|
headerHeight: headerHeight,
|
||||||
headerPeerIconSize: headerPeerIconSize,
|
headerPeerIconSize: headerPeerIconSize,
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LocationNotesView: View {
|
||||||
|
@StateObject private var manager: LocationNotesManager
|
||||||
|
let geohash: String
|
||||||
|
let senderNickname: String
|
||||||
|
let onNotesCountChanged: ((Int) -> Void)?
|
||||||
|
|
||||||
|
@ThemedPalette private var palette
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var draft: String = ""
|
||||||
|
|
||||||
|
init(
|
||||||
|
geohash: String,
|
||||||
|
senderNickname: String,
|
||||||
|
onNotesCountChanged: ((Int) -> Void)? = nil,
|
||||||
|
manager: LocationNotesManager? = nil
|
||||||
|
) {
|
||||||
|
let gh = geohash.lowercased()
|
||||||
|
self.geohash = gh
|
||||||
|
self.senderNickname = senderNickname
|
||||||
|
self.onNotesCountChanged = onNotesCountChanged
|
||||||
|
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color { palette.background }
|
||||||
|
private var accentGreen: Color { palette.accent }
|
||||||
|
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let description: LocalizedStringKey = "location_notes.description"
|
||||||
|
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||||
|
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||||
|
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||||
|
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||||
|
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||||
|
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||||
|
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||||
|
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||||
|
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||||
|
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
notesContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.themedSurface()
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||||
|
.themedSheetBackground()
|
||||||
|
.onDisappear { manager.cancel() }
|
||||||
|
.onChange(of: geohash) { newValue in
|
||||||
|
manager.setGeohash(newValue)
|
||||||
|
}
|
||||||
|
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||||
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
|
onNotesCountChanged?(newValue)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
ScrollView {
|
||||||
|
notesContent
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.themedSurface()
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
#else
|
||||||
|
.navigationTitle("")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.themedSheetBackground()
|
||||||
|
.onDisappear { manager.cancel() }
|
||||||
|
.onChange(of: geohash) { newValue in
|
||||||
|
manager.setGeohash(newValue)
|
||||||
|
}
|
||||||
|
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||||
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
|
onNotesCountChanged?(newValue)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var closeButton: some View {
|
||||||
|
SheetCloseButton { dismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
let count = manager.notes.count
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(headerTitle(for: count))
|
||||||
|
.bitchatFont(size: 18)
|
||||||
|
Spacer()
|
||||||
|
closeButton
|
||||||
|
}
|
||||||
|
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
|
||||||
|
Text(building)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(accentGreen)
|
||||||
|
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
|
||||||
|
Text(block)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(accentGreen)
|
||||||
|
}
|
||||||
|
Text(Strings.description)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if manager.state == .noRelays {
|
||||||
|
Text(Strings.relaysPaused)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.themedSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func headerTitle(for count: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||||
|
locale: .current,
|
||||||
|
"\(geohash) ± 1", count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notesContent: some View {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
|
if manager.state == .noRelays {
|
||||||
|
noRelaysRow
|
||||||
|
} else if manager.state == .loading && !manager.initialLoadComplete {
|
||||||
|
loadingRow
|
||||||
|
} else if manager.notes.isEmpty {
|
||||||
|
emptyRow
|
||||||
|
} else {
|
||||||
|
ForEach(manager.notes) { note in
|
||||||
|
noteRow(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = manager.errorMessage, manager.state != .noRelays {
|
||||||
|
errorRow(message: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
|
||||||
|
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
|
||||||
|
let ts = timestampText(for: note.createdAt)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(verbatim: "@\(baseName)")
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
if !ts.isEmpty {
|
||||||
|
Text(ts)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Text(note.content)
|
||||||
|
.bitchatFont(size: 14)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noRelaysRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.noRelaysNearby)
|
||||||
|
.bitchatFont(size: 13, weight: .semibold)
|
||||||
|
Text(Strings.relaysRetryHint)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
Button(Strings.retry) { manager.refresh() }
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text(Strings.loadingNotes)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.emptyTitle)
|
||||||
|
.bitchatFont(size: 13, weight: .semibold)
|
||||||
|
Text(Strings.emptySubtitle)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorRow(message: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
Text(message)
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Button(Strings.dismissError) { manager.clearError() }
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputSection: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.bitchatFont(size: 14)
|
||||||
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
Button(action: send) {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.bitchatSystem(size: 20))
|
||||||
|
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!sendButtonEnabled)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.themedSurface()
|
||||||
|
.overlay(Divider(), alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send() {
|
||||||
|
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||||
|
manager.send(content: content, nickname: senderNickname)
|
||||||
|
draft = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sendButtonEnabled: Bool {
|
||||||
|
!draft.trimmed.isEmpty && manager.state != .noRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timestamp Formatting
|
||||||
|
private func timestampText(for date: Date) -> String {
|
||||||
|
let now = Date()
|
||||||
|
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||||
|
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||||
|
return rel.isEmpty ? "" : "\(rel) ago"
|
||||||
|
} else {
|
||||||
|
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||||
|
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||||
|
return fmt.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let relativeFormatter: DateComponentsFormatter = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.allowedUnits = [.day, .hour, .minute]
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .abbreviated
|
||||||
|
f.collapsesLargestUnit = true
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let absDateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let absDateYearFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.setLocalizedDateFormatFromTemplate("MMM d, y")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
//
|
|
||||||
// NoticesView.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// The unified notices sheet behind the header's pin icon: one place for
|
|
||||||
/// everything pinned around you, with a scope toggle.
|
|
||||||
///
|
|
||||||
/// - geo: the current geohash's notices — mesh-synced board posts merged and
|
|
||||||
/// deduped with Nostr kind-1 location notes, so you also see notices from
|
|
||||||
/// people who aren't on your mesh.
|
|
||||||
/// - mesh: the mesh-local board only (empty geohash, fully offline).
|
|
||||||
struct NoticesView: View {
|
|
||||||
enum Tab: Hashable {
|
|
||||||
case geo
|
|
||||||
case mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderNickname: String
|
|
||||||
@ObservedObject var board: BoardManager
|
|
||||||
|
|
||||||
@ThemedPalette private var palette
|
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
||||||
@State private var tab: Tab
|
|
||||||
@State private var draft: String = ""
|
|
||||||
@State private var urgent = false
|
|
||||||
@State private var expiryDays = 7
|
|
||||||
|
|
||||||
/// Injected notes manager for tests; live use derives one per geohash.
|
|
||||||
private let notesManager: LocationNotesManager?
|
|
||||||
|
|
||||||
init(
|
|
||||||
senderNickname: String,
|
|
||||||
board: BoardManager,
|
|
||||||
initialTab: Tab,
|
|
||||||
notesManager: LocationNotesManager? = nil
|
|
||||||
) {
|
|
||||||
self.senderNickname = senderNickname
|
|
||||||
self.board = board
|
|
||||||
self.notesManager = notesManager
|
|
||||||
_tab = State(initialValue: initialTab)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
|
||||||
|
|
||||||
/// The geohash the geo tab is scoped to: the selected location channel,
|
|
||||||
/// or the device's building geohash when chatting on mesh.
|
|
||||||
private var geoGeohash: String? {
|
|
||||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
|
||||||
return channel.geohash
|
|
||||||
}
|
|
||||||
return locationChannelsModel.currentBuildingGeohash
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The geo scope comes from device location only when no location channel
|
|
||||||
/// is selected; that's the case that needs the location machinery.
|
|
||||||
private var geoTabNeedsDeviceLocation: Bool {
|
|
||||||
if case .location = locationChannelsModel.selectedChannel { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private var activeGeohash: String? {
|
|
||||||
switch tab {
|
|
||||||
case .geo: return geoGeohash
|
|
||||||
case .mesh: return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Strings {
|
|
||||||
static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet")
|
|
||||||
static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices")
|
|
||||||
static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices")
|
|
||||||
static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle")
|
|
||||||
// The pre-merge location-notes explainer, reused so its existing
|
|
||||||
// translations carry over.
|
|
||||||
static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet")
|
|
||||||
static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet")
|
|
||||||
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
|
||||||
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
|
||||||
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
|
||||||
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
|
||||||
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
|
||||||
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
|
||||||
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
|
||||||
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
|
||||||
static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button")
|
|
||||||
static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh")
|
|
||||||
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
|
|
||||||
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
|
|
||||||
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
|
|
||||||
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
|
||||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
|
||||||
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
|
||||||
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
|
||||||
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
|
||||||
|
|
||||||
static func expiryDaysOption(_ days: Int) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
|
||||||
locale: .current,
|
|
||||||
days
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
|
||||||
let base = String(
|
|
||||||
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
|
||||||
locale: .current,
|
|
||||||
author, content
|
|
||||||
)
|
|
||||||
return urgent ? "\(urgentBadge), \(base)" : base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
headerSection
|
|
||||||
contentSection
|
|
||||||
if activeGeohash != nil {
|
|
||||||
composer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.themedSurface()
|
|
||||||
#if os(macOS)
|
|
||||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
|
||||||
#endif
|
|
||||||
.themedSheetBackground()
|
|
||||||
.onAppear { beginGeoLocationIfNeeded() }
|
|
||||||
.onChange(of: tab) { newTab in
|
|
||||||
if newTab == .geo {
|
|
||||||
beginGeoLocationIfNeeded()
|
|
||||||
} else {
|
|
||||||
locationChannelsModel.endLiveRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Catches permission granted from the geo tab's enable button.
|
|
||||||
.onChange(of: locationChannelsModel.permissionState) { _ in
|
|
||||||
beginGeoLocationIfNeeded()
|
|
||||||
}
|
|
||||||
.onDisappear { locationChannelsModel.endLiveRefresh() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The geo tab tracks the device's building geohash while on mesh; keep
|
|
||||||
/// location fresh only in that case (a selected location channel already
|
|
||||||
/// fixes the scope).
|
|
||||||
private func beginGeoLocationIfNeeded() {
|
|
||||||
guard tab == .geo, geoTabNeedsDeviceLocation,
|
|
||||||
locationChannelsModel.permissionState == .authorized else { return }
|
|
||||||
locationChannelsModel.enableLocationChannels()
|
|
||||||
locationChannelsModel.beginLiveRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var headerSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text(verbatim: scopeTitle)
|
|
||||||
.bitchatFont(size: 18)
|
|
||||||
Spacer()
|
|
||||||
SheetCloseButton { dismiss() }
|
|
||||||
.accessibilityLabel(Strings.closeHint)
|
|
||||||
}
|
|
||||||
Picker(Strings.scopePicker, selection: $tab) {
|
|
||||||
Text(Strings.geoTab).tag(Tab.geo)
|
|
||||||
Text(Strings.meshTab).tag(Tab.mesh)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.accessibilityLabel(Strings.scopePicker)
|
|
||||||
Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 16)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
.themedSurface()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var scopeTitle: String {
|
|
||||||
switch tab {
|
|
||||||
case .mesh:
|
|
||||||
return "\(Strings.title) @ #mesh"
|
|
||||||
case .geo:
|
|
||||||
if let geohash = geoGeohash {
|
|
||||||
return "\(Strings.title) @ #\(geohash)"
|
|
||||||
}
|
|
||||||
return Strings.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var contentSection: some View {
|
|
||||||
switch tab {
|
|
||||||
case .mesh:
|
|
||||||
NoticesList(
|
|
||||||
items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []),
|
|
||||||
showsSource: false,
|
|
||||||
board: board,
|
|
||||||
notesManager: nil
|
|
||||||
)
|
|
||||||
case .geo:
|
|
||||||
if let geohash = geoGeohash {
|
|
||||||
GeoNoticesList(geohash: geohash, board: board, manager: notesManager)
|
|
||||||
} else {
|
|
||||||
locationUnavailableSection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var locationUnavailableSection: some View {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text(Strings.locationUnavailable)
|
|
||||||
.bitchatFont(size: 14)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
Button(Strings.enableLocation) {
|
|
||||||
locationChannelsModel.enableAndRefresh()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.themedSurface()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var composer: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(alignment: .top, spacing: 10) {
|
|
||||||
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.bitchatFont(size: 14)
|
|
||||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
Button(action: send) {
|
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
|
||||||
.font(.bitchatSystem(size: 20))
|
|
||||||
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
|
||||||
}
|
|
||||||
.padding(.top, 2)
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!sendEnabled)
|
|
||||||
.accessibilityLabel(Strings.send)
|
|
||||||
}
|
|
||||||
// Urgency and expiry only travel with the mesh copy — the bridged
|
|
||||||
// Nostr note carries neither, so relay-side readers would never
|
|
||||||
// see them. Offer the controls only where they fully apply.
|
|
||||||
if tab == .mesh {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Toggle(isOn: $urgent) {
|
|
||||||
Text(Strings.urgentToggle)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
|
||||||
}
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.fixedSize()
|
|
||||||
.accessibilityLabel(Strings.urgentToggle)
|
|
||||||
Spacer()
|
|
||||||
Text(Strings.expiryLabel)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
|
||||||
ForEach([1, 3, 7], id: \.self) { days in
|
|
||||||
Text(Strings.expiryDaysOption(days)).tag(days)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.fixedSize()
|
|
||||||
.accessibilityLabel(Strings.expiryLabel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.themedSurface()
|
|
||||||
.overlay(Divider(), alignment: .top)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sendEnabled: Bool {
|
|
||||||
let trimmed = draft.trimmed
|
|
||||||
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private func send() {
|
|
||||||
guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return }
|
|
||||||
// Geo posts go to the board and are bridged to Nostr by BoardManager,
|
|
||||||
// so mesh and internet see the same notice. They always use the
|
|
||||||
// defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy).
|
|
||||||
let sent = board.createPost(
|
|
||||||
content: content,
|
|
||||||
geohash: geohash,
|
|
||||||
urgent: tab == .mesh && urgent,
|
|
||||||
expiryDays: tab == .mesh ? expiryDays : 7,
|
|
||||||
nickname: senderNickname
|
|
||||||
)
|
|
||||||
if sent {
|
|
||||||
draft = ""
|
|
||||||
urgent = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The geo tab's list: owns the Nostr notes subscription for the scope
|
|
||||||
/// geohash and merges it with the board posts for the same geohash.
|
|
||||||
private struct GeoNoticesList: View {
|
|
||||||
let geohash: String
|
|
||||||
@ObservedObject var board: BoardManager
|
|
||||||
@StateObject private var notesManager: LocationNotesManager
|
|
||||||
|
|
||||||
init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) {
|
|
||||||
let gh = geohash.lowercased()
|
|
||||||
self.geohash = gh
|
|
||||||
self.board = board
|
|
||||||
_notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NoticesList(
|
|
||||||
items: UnifiedNotices.merge(
|
|
||||||
posts: board.posts(forGeohash: geohash),
|
|
||||||
notes: notesManager.notes
|
|
||||||
),
|
|
||||||
showsSource: true,
|
|
||||||
board: board,
|
|
||||||
notesManager: notesManager
|
|
||||||
)
|
|
||||||
.onChange(of: geohash) { newValue in
|
|
||||||
notesManager.setGeohash(newValue)
|
|
||||||
}
|
|
||||||
.onDisappear { notesManager.cancel() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders merged notices with per-source affordances: swipe-delete for own
|
|
||||||
/// items and a mesh/net badge when sources mix.
|
|
||||||
private struct NoticesList: View {
|
|
||||||
let items: [NoticeItem]
|
|
||||||
let showsSource: Bool
|
|
||||||
let board: BoardManager
|
|
||||||
let notesManager: LocationNotesManager?
|
|
||||||
|
|
||||||
@ThemedPalette private var palette
|
|
||||||
|
|
||||||
private typealias Strings = NoticesView.Strings
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if items.isEmpty {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
statusRows
|
|
||||||
if showEmptyState {
|
|
||||||
Text(Strings.emptyTitle)
|
|
||||||
.bitchatFont(size: 13, weight: .semibold)
|
|
||||||
Text(Strings.emptySubtitle)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
List {
|
|
||||||
statusRows
|
|
||||||
.listRowBackground(palette.background)
|
|
||||||
.listRowSeparatorTint(palette.divider)
|
|
||||||
ForEach(items) { item in
|
|
||||||
row(item)
|
|
||||||
.listRowBackground(palette.background)
|
|
||||||
.listRowSeparatorTint(palette.divider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.themedSurface()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notes may still be loading or unreachable; only claim "no notices yet"
|
|
||||||
/// once the sources settled.
|
|
||||||
private var showEmptyState: Bool {
|
|
||||||
guard let notesManager else { return true }
|
|
||||||
return notesManager.initialLoadComplete && notesManager.state != .loading
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var statusRows: some View {
|
|
||||||
if let notesManager {
|
|
||||||
if notesManager.state == .loading && !notesManager.initialLoadComplete {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
ProgressView()
|
|
||||||
Text(Strings.loadingNotes)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
} else if notesManager.state == .noRelays {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(Strings.noRelaysNearby)
|
|
||||||
.bitchatFont(size: 13, weight: .semibold)
|
|
||||||
Text(Strings.relaysRetryHint)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
Button(Strings.retry) { notesManager.refresh() }
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
} else if let error = notesManager.errorMessage {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
Text(error)
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Button(Strings.dismissError) { notesManager.clearError() }
|
|
||||||
.bitchatFont(size: 12)
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func canDelete(_ item: NoticeItem) -> Bool {
|
|
||||||
switch item.source {
|
|
||||||
case .board(let post):
|
|
||||||
return board.isOwnPost(post)
|
|
||||||
case .nostr(let note):
|
|
||||||
return notesManager?.isOwnNote(note) ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func delete(_ item: NoticeItem) {
|
|
||||||
switch item.source {
|
|
||||||
case .board(let post):
|
|
||||||
// Tombstones the board post and retracts the bridged Nostr copy.
|
|
||||||
board.deletePost(post)
|
|
||||||
case .nostr(let note):
|
|
||||||
notesManager?.delete(note: note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func row(_ item: NoticeItem) -> some View {
|
|
||||||
let isOwn = canDelete(item)
|
|
||||||
return VStack(alignment: .leading, spacing: 2) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
if item.isUrgent {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.font(.bitchatSystem(size: 11))
|
|
||||||
.foregroundColor(palette.alertRed)
|
|
||||||
Text(Strings.urgentBadge)
|
|
||||||
.bitchatFont(size: 11, weight: .semibold)
|
|
||||||
.foregroundColor(palette.alertRed)
|
|
||||||
}
|
|
||||||
Text(verbatim: "@\(item.author)")
|
|
||||||
.bitchatFont(size: 12, weight: .semibold)
|
|
||||||
Text(Self.timestampText(for: item.createdAt))
|
|
||||||
.bitchatFont(size: 11)
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
Spacer()
|
|
||||||
if showsSource {
|
|
||||||
sourceBadge(item)
|
|
||||||
}
|
|
||||||
if isOwn {
|
|
||||||
Button {
|
|
||||||
delete(item)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.font(.bitchatSystem(size: 12))
|
|
||||||
.foregroundColor(palette.secondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(Strings.deleteAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(item.content)
|
|
||||||
.bitchatFont(size: 14)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
.accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent))
|
|
||||||
.accessibilityActions {
|
|
||||||
if isOwn {
|
|
||||||
Button(Strings.deleteAction) { delete(item) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
||||||
if isOwn {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
delete(item)
|
|
||||||
} label: {
|
|
||||||
Label(Strings.deleteAction, systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sourceBadge(_ item: NoticeItem) -> some View {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe")
|
|
||||||
.font(.bitchatSystem(size: 10))
|
|
||||||
Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
|
|
||||||
.bitchatFont(size: 10)
|
|
||||||
}
|
|
||||||
.foregroundColor(palette.secondary.opacity(0.8))
|
|
||||||
.accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timestamp Formatting
|
|
||||||
|
|
||||||
private static func timestampText(for date: Date) -> String {
|
|
||||||
let now = Date()
|
|
||||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
|
||||||
let rel = relativeFormatter.string(from: date, to: now) ?? ""
|
|
||||||
return rel.isEmpty ? "" : "\(rel) ago"
|
|
||||||
}
|
|
||||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
|
||||||
return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let relativeFormatter: DateComponentsFormatter = {
|
|
||||||
let f = DateComponentsFormatter()
|
|
||||||
f.allowedUnits = [.day, .hour, .minute]
|
|
||||||
f.maximumUnitCount = 1
|
|
||||||
f.unitsStyle = .abbreviated
|
|
||||||
f.collapsesLargestUnit = true
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let absDateFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.setLocalizedDateFormatFromTemplate("MMM d")
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let absDateYearFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.setLocalizedDateFormatFromTemplate("MMM d, y")
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -217,6 +217,51 @@ struct BLEServiceCoreTests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: a stable/verified private chat addresses the peer by its
|
||||||
|
// full 64-hex Noise key, but the Noise session (and capabilities/
|
||||||
|
// connection) are keyed by the short routing ID. The Wi-Fi bulk send path
|
||||||
|
// must normalize the ID first, or `hasEstablishedSession` misses and
|
||||||
|
// Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer.
|
||||||
|
@Test
|
||||||
|
func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws {
|
||||||
|
let ble = makeService()
|
||||||
|
|
||||||
|
let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) })
|
||||||
|
let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key
|
||||||
|
#expect(fullKey.noiseKey != nil)
|
||||||
|
let shortID = fullKey.toShort() // 16-hex routing ID
|
||||||
|
#expect(shortID != fullKey)
|
||||||
|
|
||||||
|
// Establish a real Noise session in the service's noise engine, keyed
|
||||||
|
// by the short routing ID (exactly as a completed handshake would).
|
||||||
|
let peer = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let noise = ble._test_noiseService
|
||||||
|
let m1 = try noise.initiateHandshake(with: shortID)
|
||||||
|
let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1))
|
||||||
|
let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2))
|
||||||
|
_ = try peer.processHandshakeMessage(from: shortID, message: m3)
|
||||||
|
|
||||||
|
#expect(noise.hasEstablishedSession(with: shortID))
|
||||||
|
// The bug in raw form: querying by the 64-hex key misses the session.
|
||||||
|
#expect(!noise.hasEstablishedSession(with: fullKey))
|
||||||
|
|
||||||
|
// Register the peer as a directly-connected `.wifiBulk` neighbor.
|
||||||
|
ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk])
|
||||||
|
|
||||||
|
let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
|
||||||
|
// The send path normalizes first, so eligibility + offer resolve for
|
||||||
|
// both the short ID and the full 64-hex Noise key.
|
||||||
|
let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID)
|
||||||
|
let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey)
|
||||||
|
#expect(viaShort.hasEstablishedNoiseSession)
|
||||||
|
#expect(viaFull.hasEstablishedNoiseSession)
|
||||||
|
#expect(viaFull.isDirectlyConnected)
|
||||||
|
#expect(viaFull.peerCapabilities.contains(.wifiBulk))
|
||||||
|
#expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true))
|
||||||
|
#expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true))
|
||||||
|
}
|
||||||
|
|
||||||
/// Pings are unsigned, so their claimed sender is attacker-controlled.
|
/// Pings are unsigned, so their claimed sender is attacker-controlled.
|
||||||
/// The pong budget must be keyed on the ingress link (the directly
|
/// The pong budget must be keyed on the ingress link (the directly
|
||||||
/// connected peer that delivered the packet): rotating forged sender IDs
|
/// connected peer that delivered the packet): rotating forged sender IDs
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
verifyResponsePayloads.append((peerID, payload))
|
verifyResponsePayloads.append((peerID, payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
|
||||||
|
|
||||||
|
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||||
|
vouchPayloads.append((peerID, payload))
|
||||||
|
}
|
||||||
|
|
||||||
// Group payloads
|
// Group payloads
|
||||||
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
|
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||||
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
|
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||||
@@ -168,12 +174,6 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||||
groupKeyUpdatePayloads.append((peerID, payload))
|
groupKeyUpdatePayloads.append((peerID, payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
|
|
||||||
|
|
||||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
|
||||||
vouchPayloads.append((peerID, payload))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
|
|
||||||
func saveIdentityState() { saveIdentityStateCount += 1 }
|
func saveIdentityState() { saveIdentityStateCount += 1 }
|
||||||
|
|
||||||
private(set) var vouchToConnectedVerifiedPeersCount = 0
|
|
||||||
func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 }
|
|
||||||
|
|
||||||
// Encryption status
|
// Encryption status
|
||||||
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
|
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
|
||||||
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
|
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
|
||||||
|
|||||||
@@ -60,12 +60,8 @@ private final class MockChatVouchContext: ChatVouchContext {
|
|||||||
private(set) var installedObservers: [(PeerID, String) -> Void] = []
|
private(set) var installedObservers: [(PeerID, String) -> Void] = []
|
||||||
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
|
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
|
||||||
|
|
||||||
var connectedPeerIDList: [PeerID] = []
|
|
||||||
|
|
||||||
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
|
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
|
||||||
|
|
||||||
func connectedPeerIDs() -> [PeerID] { connectedPeerIDList }
|
|
||||||
|
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
installedObservers.append(handler)
|
installedObservers.append(handler)
|
||||||
}
|
}
|
||||||
@@ -139,78 +135,14 @@ struct ChatVouchCoordinatorContextTests {
|
|||||||
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
||||||
#expect(context.sentVouchPayloads.isEmpty)
|
#expect(context.sentVouchPayloads.isEmpty)
|
||||||
|
|
||||||
// Verified but advertises a non-empty capability set lacking .vouch:
|
// Verified but no .vouch capability: nothing.
|
||||||
// nothing. (An *empty*/unknown set is race-tolerant and still sends —
|
|
||||||
// see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
|
|
||||||
context.verifiedFingerprints.insert(peerFingerprint)
|
context.verifiedFingerprints.insert(peerFingerprint)
|
||||||
context.capabilitiesByPeerID[peerID] = [.prekeys]
|
context.capabilitiesByPeerID[peerID] = []
|
||||||
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
||||||
#expect(context.sentVouchPayloads.isEmpty)
|
#expect(context.sentVouchPayloads.isEmpty)
|
||||||
#expect(context.markedBatchSent.isEmpty)
|
#expect(context.markedBatchSent.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Capability race tolerance & new triggers
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func attemptVouch_sendsWhenCapabilitiesUnknown() {
|
|
||||||
// Capability set still empty at attempt time (the peer's .vouch bit
|
|
||||||
// arrives on a later announce): the batch must still go out.
|
|
||||||
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
||||||
context.capabilitiesByPeerID[peerID] = []
|
|
||||||
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
||||||
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
||||||
|
|
||||||
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
|
||||||
#expect(context.sentVouchPayloads.count == 1)
|
|
||||||
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() {
|
|
||||||
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
||||||
context.connectedPeerIDList = [peerID]
|
|
||||||
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
||||||
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
||||||
|
|
||||||
// Session is already up (no peerAuthenticated re-fire); the verify pass
|
|
||||||
// is what makes the batch go out.
|
|
||||||
coordinator.vouchToConnectedVerifiedPeers()
|
|
||||||
let sent = context.sentVouchPayloads
|
|
||||||
#expect(sent.count == 1)
|
|
||||||
#expect(sent.first?.peerID == peerID)
|
|
||||||
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() {
|
|
||||||
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
||||||
context.connectedPeerIDList = [peerID]
|
|
||||||
context.verifiedFingerprints.remove(peerFingerprint)
|
|
||||||
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
||||||
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
||||||
|
|
||||||
coordinator.vouchToConnectedVerifiedPeers()
|
|
||||||
#expect(context.sentVouchPayloads.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() {
|
|
||||||
let (context, coordinator) = makeVerifiedCapablePeer()
|
|
||||||
context.recentVerified = [String(repeating: "01", count: 32)]
|
|
||||||
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
|
|
||||||
|
|
||||||
// First announce before the .vouch bit is known: empty set is
|
|
||||||
// race-tolerant, so it already sends and stamps the throttle.
|
|
||||||
context.capabilitiesByPeerID[peerID] = []
|
|
||||||
coordinator.peersUpdated([peerID])
|
|
||||||
#expect(context.sentVouchPayloads.count == 1)
|
|
||||||
|
|
||||||
// A later announce carrying .vouch must not double-send (throttled).
|
|
||||||
context.capabilitiesByPeerID[peerID] = [.vouch]
|
|
||||||
coordinator.peersUpdated([peerID])
|
|
||||||
#expect(context.sentVouchPayloads.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
|
func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
|
||||||
let (context, coordinator) = makeVerifiedCapablePeer()
|
let (context, coordinator) = makeVerifiedCapablePeer()
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
// Silence the prekey round so the maintenance barrier below emits nothing.
|
||||||
config.prekeyBundleSyncIntervalSeconds = 0
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
@@ -544,6 +545,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
// Silence the prekey round so the maintenance barrier isolates fragments.
|
||||||
config.prekeyBundleSyncIntervalSeconds = 0
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
@@ -586,30 +588,6 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(sent.isRSR)
|
#expect(sent.isRSR)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
|
|
||||||
var config = GossipSyncManager.Config()
|
|
||||||
config.messageSyncIntervalSeconds = 0
|
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
|
||||||
let delegate = RecordingDelegate()
|
|
||||||
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
|
|
||||||
manager.delegate = delegate
|
|
||||||
|
|
||||||
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
|
||||||
manager.requestMissingFragments(fragmentIDs: [stalledID])
|
|
||||||
|
|
||||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
|
||||||
let sent = try #require(delegate.packets.first)
|
|
||||||
#expect(sent.type == MessageType.requestSync.rawValue)
|
|
||||||
#expect(sent.ttl == 0)
|
|
||||||
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
|
|
||||||
#expect(request.types == .fragment)
|
|
||||||
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
|
|
||||||
#expect(ids == Set([stalledID]))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
|
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
|
||||||
var config = GossipSyncManager.Config()
|
var config = GossipSyncManager.Config()
|
||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
@@ -697,6 +675,31 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(manager._hasPrekeyBundle(for: ownerPeer))
|
#expect(manager._hasPrekeyBundle(for: ownerPeer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
|
||||||
|
var config = GossipSyncManager.Config()
|
||||||
|
config.messageSyncIntervalSeconds = 0
|
||||||
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
|
||||||
|
let requestSyncManager = RequestSyncManager()
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||||
|
let delegate = RecordingDelegate()
|
||||||
|
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
|
||||||
|
manager.delegate = delegate
|
||||||
|
|
||||||
|
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
||||||
|
manager.requestMissingFragments(fragmentIDs: [stalledID])
|
||||||
|
|
||||||
|
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||||
|
let sent = try #require(delegate.packets.first)
|
||||||
|
#expect(sent.type == MessageType.requestSync.rawValue)
|
||||||
|
#expect(sent.ttl == 0)
|
||||||
|
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
|
||||||
|
#expect(request.types == .fragment)
|
||||||
|
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
|
||||||
|
#expect(ids == Set([stalledID]))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Archive persistence
|
// MARK: - Archive persistence
|
||||||
|
|
||||||
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Guards against locale gaps in the string catalogs: every translatable key
|
|
||||||
/// must have a localization for every supported locale, so no user ever sees
|
|
||||||
/// an English fallback (see PR #1391 review).
|
|
||||||
struct LocalizationCoverageTests {
|
|
||||||
private static let repoRoot = URL(fileURLWithPath: #filePath)
|
|
||||||
.deletingLastPathComponent() // bitchatTests
|
|
||||||
.deletingLastPathComponent() // repo root
|
|
||||||
|
|
||||||
private struct Catalog {
|
|
||||||
let sourceLanguage: String
|
|
||||||
/// key -> set of locales with a localization entry
|
|
||||||
let coverage: [String: Set<String>]
|
|
||||||
/// all locales appearing anywhere in the catalog
|
|
||||||
var allLocales: Set<String> { coverage.values.reduce(into: []) { $0.formUnion($1) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadCatalog(_ relativePath: String) throws -> Catalog {
|
|
||||||
let url = repoRoot.appendingPathComponent(relativePath)
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
|
||||||
let strings = try #require(root["strings"] as? [String: Any])
|
|
||||||
let sourceLanguage = try #require(root["sourceLanguage"] as? String)
|
|
||||||
|
|
||||||
var coverage: [String: Set<String>] = [:]
|
|
||||||
for (key, value) in strings {
|
|
||||||
guard let entry = value as? [String: Any] else { continue }
|
|
||||||
if entry["shouldTranslate"] as? Bool == false { continue }
|
|
||||||
let localizations = entry["localizations"] as? [String: Any] ?? [:]
|
|
||||||
var locales: Set<String> = []
|
|
||||||
for (locale, loc) in localizations {
|
|
||||||
guard let loc = loc as? [String: Any] else { continue }
|
|
||||||
// A localization counts if it has a non-empty stringUnit value
|
|
||||||
// or uses variations/substitutions (plural forms).
|
|
||||||
if let unit = loc["stringUnit"] as? [String: Any],
|
|
||||||
let unitValue = unit["value"] as? String, !unitValue.isEmpty {
|
|
||||||
locales.insert(locale)
|
|
||||||
} else if loc["variations"] != nil || loc["substitutions"] != nil {
|
|
||||||
locales.insert(locale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
coverage[key] = locales
|
|
||||||
}
|
|
||||||
return Catalog(sourceLanguage: sourceLanguage, coverage: coverage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mainCatalogCoversAllLocalesForEveryKey() throws {
|
|
||||||
let catalog = try Self.loadCatalog("bitchat/Localizable.xcstrings")
|
|
||||||
let expected = catalog.allLocales
|
|
||||||
#expect(expected.count > 1, "catalog should declare more locales than the source language")
|
|
||||||
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
|
|
||||||
let missing = expected.subtracting(locales).sorted()
|
|
||||||
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func shareExtensionCatalogCoversAllLocalesForEveryKey() throws {
|
|
||||||
let catalog = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
|
|
||||||
let expected = catalog.allLocales
|
|
||||||
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
|
|
||||||
let missing = expected.subtracting(locales).sorted()
|
|
||||||
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func shareExtensionSupportsSameLocalesAsMainApp() throws {
|
|
||||||
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
|
|
||||||
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
|
|
||||||
let missing = main.allLocales.subtracting(shareExt.allLocales).sorted()
|
|
||||||
#expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,17 +40,14 @@ struct BLEFragmentHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func ownFragmentIsTrackedForSyncButNotAssembled() {
|
func ownFragmentIsIgnored() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2)
|
let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2)
|
||||||
|
|
||||||
handler.handle(packet, from: localPeerID)
|
handler.handle(packet, from: localPeerID)
|
||||||
|
|
||||||
// Sync replay hands own fragments back after a relaunch; they must
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
// re-enter the sync store (so the next round's filter covers them
|
|
||||||
// and redelivery stops) without being reassembled.
|
|
||||||
#expect(recorder.trackedPackets.count == 1)
|
|
||||||
#expect(recorder.appendedHeaders.isEmpty)
|
#expect(recorder.appendedHeaders.isEmpty)
|
||||||
#expect(recorder.reinjectedPackets.isEmpty)
|
#expect(recorder.reinjectedPackets.isEmpty)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
//
|
|
||||||
// BLERecentPeripheralCacheTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Eviction, expiry, and reconnect-target selection for the background
|
|
||||||
// wake-on-proximity peripheral cache.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct BLERecentPeripheralCacheTests {
|
|
||||||
|
|
||||||
private let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
|
|
||||||
private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache<String> {
|
|
||||||
BLERecentPeripheralCache<String>(capacity: capacity, maxAge: maxAge)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func recordUpsertsByPeripheralID() {
|
|
||||||
let cache = makeCache()
|
|
||||||
cache.record("p1", peripheralID: "A", at: base)
|
|
||||||
cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10))
|
|
||||||
|
|
||||||
#expect(cache.count == 1)
|
|
||||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false }
|
|
||||||
#expect(targets.map(\.peripheral) == ["p1-updated"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func overCapacityEvictsStalestEntry() {
|
|
||||||
let cache = makeCache(capacity: 2)
|
|
||||||
cache.record("p1", peripheralID: "A", at: base)
|
|
||||||
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
|
|
||||||
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2))
|
|
||||||
|
|
||||||
#expect(cache.count == 2)
|
|
||||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false }
|
|
||||||
#expect(targets.map(\.peripheralID) == ["C", "B"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func refreshingAnEntryProtectsItFromEviction() {
|
|
||||||
let cache = makeCache(capacity: 2)
|
|
||||||
cache.record("p1", peripheralID: "A", at: base)
|
|
||||||
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
|
|
||||||
// A becomes the freshest again; adding C must evict B, not A
|
|
||||||
cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2))
|
|
||||||
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3))
|
|
||||||
|
|
||||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false }
|
|
||||||
#expect(targets.map(\.peripheralID) == ["C", "A"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func expiredEntriesArePruned() {
|
|
||||||
let cache = makeCache(maxAge: 100)
|
|
||||||
cache.record("p1", peripheralID: "A", at: base)
|
|
||||||
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50))
|
|
||||||
|
|
||||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false }
|
|
||||||
#expect(targets.map(\.peripheralID) == ["B"])
|
|
||||||
#expect(cache.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func targetsAreFreshestFirstAndCappedAtLimit() {
|
|
||||||
let cache = makeCache(capacity: 8)
|
|
||||||
for (index, id) in ["A", "B", "C", "D"].enumerated() {
|
|
||||||
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false }
|
|
||||||
#expect(targets.map(\.peripheralID) == ["D", "C"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() {
|
|
||||||
let cache = makeCache(capacity: 8)
|
|
||||||
for (index, id) in ["A", "B", "C"].enumerated() {
|
|
||||||
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// C (freshest) is already connected; the two slots go to B and A
|
|
||||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" }
|
|
||||||
#expect(targets.map(\.peripheralID) == ["B", "A"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func nonPositiveLimitReturnsNothing() {
|
|
||||||
let cache = makeCache()
|
|
||||||
cache.record("p1", peripheralID: "A", at: base)
|
|
||||||
|
|
||||||
#expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty)
|
|
||||||
#expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,13 +32,13 @@ struct BLESourceRouteOriginationPolicyTests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func route(
|
private func decide(
|
||||||
packet: BitchatPacket,
|
packet: BitchatPacket,
|
||||||
isRecipientConnected: Bool = false,
|
isRecipientConnected: Bool = false,
|
||||||
shouldAttemptRoute: Bool = true,
|
shouldAttemptRoute: Bool = true,
|
||||||
computedRoute: [Data]? = nil
|
computedRoute: [Data]? = nil
|
||||||
) -> [Data]? {
|
) -> BLESourceRouteOriginationPolicy.Decision {
|
||||||
BLESourceRouteOriginationPolicy.route(
|
BLESourceRouteOriginationPolicy.decide(
|
||||||
for: packet,
|
for: packet,
|
||||||
to: recipient,
|
to: recipient,
|
||||||
localPeerIDData: localPeerIDData,
|
localPeerIDData: localPeerIDData,
|
||||||
@@ -49,38 +49,38 @@ struct BLESourceRouteOriginationPolicyTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func routesWhenAllGatesPass() {
|
@Test func routesWhenAllGatesPass() {
|
||||||
#expect(route(packet: makePacket()) == [hop])
|
#expect(decide(packet: makePacket()) == .route([hop]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func relayedPacketNeverGetsRoute() {
|
@Test func relayedPacketNeverGetsRoute() {
|
||||||
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
|
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
|
||||||
#expect(route(packet: relayed) == nil)
|
#expect(decide(packet: relayed) == .flood(.relayedNotOriginator))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func broadcastRecipientNeverGetsRoute() {
|
@Test func broadcastRecipientNeverGetsRoute() {
|
||||||
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
|
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
|
||||||
#expect(route(packet: broadcast) == nil)
|
#expect(decide(packet: broadcast) == .flood(.broadcast))
|
||||||
let noRecipient = makePacket(recipientID: nil)
|
let noRecipient = makePacket(recipientID: nil)
|
||||||
#expect(route(packet: noRecipient) == nil)
|
#expect(decide(packet: noRecipient) == .flood(.broadcast))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func linkLocalTTLNeverGetsRoute() {
|
@Test func linkLocalTTLNeverGetsRoute() {
|
||||||
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
|
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
|
||||||
#expect(route(packet: makePacket(ttl: 0)) == nil)
|
#expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom))
|
||||||
#expect(route(packet: makePacket(ttl: 1)) == nil)
|
#expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func directlyConnectedRecipientNeverGetsRoute() {
|
@Test func directlyConnectedRecipientNeverGetsRoute() {
|
||||||
#expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
|
#expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func suppressedRecipientFallsBackToFlood() {
|
@Test func suppressedRecipientFallsBackToFlood() {
|
||||||
#expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
|
#expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingOrEmptyRouteFallsBackToFlood() {
|
@Test func missingOrEmptyRouteFallsBackToFlood() {
|
||||||
var sawComputeRoute = false
|
var sawComputeRoute = false
|
||||||
let result = BLESourceRouteOriginationPolicy.route(
|
let result = BLESourceRouteOriginationPolicy.decide(
|
||||||
for: makePacket(),
|
for: makePacket(),
|
||||||
to: recipient,
|
to: recipient,
|
||||||
localPeerIDData: localPeerIDData,
|
localPeerIDData: localPeerIDData,
|
||||||
@@ -91,8 +91,8 @@ struct BLESourceRouteOriginationPolicyTests {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
#expect(result == nil)
|
#expect(result == .flood(.noPath))
|
||||||
#expect(sawComputeRoute)
|
#expect(sawComputeRoute)
|
||||||
#expect(route(packet: makePacket(), computedRoute: []) == nil)
|
#expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
//
|
|
||||||
// BoardAlertsModelTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct BoardAlertsModelTests {
|
|
||||||
|
|
||||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
|
||||||
private let ownKey = Data(repeating: 7, count: 32)
|
|
||||||
|
|
||||||
private final class Harness {
|
|
||||||
var lines: [(content: String, geohash: String)] = []
|
|
||||||
var pendingFlushes: [@MainActor () -> Void] = []
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func flushAll() {
|
|
||||||
let flushes = pendingFlushes
|
|
||||||
pendingFlushes = []
|
|
||||||
for flush in flushes { flush() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel {
|
|
||||||
let fixedNow = now ?? baseDate
|
|
||||||
return BoardAlertsModel(
|
|
||||||
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
|
|
||||||
dependencies: BoardAlertsModel.Dependencies(
|
|
||||||
isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey },
|
|
||||||
emitSystemLine: { content, geohash in
|
|
||||||
harness.lines.append((content, geohash))
|
|
||||||
},
|
|
||||||
now: { fixedNow },
|
|
||||||
scheduleFlush: { flush in
|
|
||||||
harness.pendingFlushes.append(flush)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makePost(
|
|
||||||
content: String = "hello",
|
|
||||||
geohash: String = "9q8yy",
|
|
||||||
nickname: String = "alice",
|
|
||||||
createdAt: UInt64? = nil,
|
|
||||||
urgent: Bool = false,
|
|
||||||
authorKey: Data = Data(repeating: 1, count: 32),
|
|
||||||
postID: Data? = nil
|
|
||||||
) -> BoardPostPacket {
|
|
||||||
BoardPostPacket(
|
|
||||||
postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
|
|
||||||
geohash: geohash,
|
|
||||||
content: content,
|
|
||||||
authorSigningKey: authorKey,
|
|
||||||
authorNickname: nickname,
|
|
||||||
createdAt: createdAt ?? baseMs,
|
|
||||||
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
|
|
||||||
flags: urgent ? BoardPostPacket.urgentFlag : 0,
|
|
||||||
signature: Data(repeating: 2, count: 64)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func ownPosts_neverBadgeOrAlert() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(urgent: true, authorKey: ownKey))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
|
||||||
#expect(harness.lines.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func routinePost_badgesWithoutChatLine() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(geohash: ""))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(model.unseenCount(forGeohash: "") == 1)
|
|
||||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
|
||||||
#expect(harness.lines.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func urgentRecentPost_emitsLineInMatchingScope() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true))
|
|
||||||
#expect(harness.lines.isEmpty)
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(harness.lines.count == 1)
|
|
||||||
#expect(harness.lines[0].geohash == "9q8yy")
|
|
||||||
#expect(harness.lines[0].content.contains("road closed"))
|
|
||||||
#expect(harness.lines[0].content.contains("@alice"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func urgentBackfilledPost_badgesOnly() {
|
|
||||||
let harness = Harness()
|
|
||||||
let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120)
|
|
||||||
let model = makeModel(harness: harness, now: arrivalTime)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(createdAt: baseMs, urgent: true))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
|
|
||||||
#expect(harness.lines.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func simultaneousUrgentPosts_collapseIntoOneLine() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(content: "one", urgent: true))
|
|
||||||
model.handleArrival(makePost(content: "two", urgent: true))
|
|
||||||
model.handleArrival(makePost(content: "three", urgent: true))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(harness.lines.count == 1)
|
|
||||||
#expect(harness.lines[0].content.contains("3"))
|
|
||||||
#expect(harness.pendingFlushes.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func urgentPostsInDifferentScopes_alertEachScope() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true))
|
|
||||||
model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(harness.lines.count == 2)
|
|
||||||
#expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func duplicateArrival_isHandledOnce() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
let id = Data(repeating: 3, count: 16)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(urgent: true, postID: id))
|
|
||||||
model.handleArrival(makePost(urgent: true, postID: id))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
|
|
||||||
#expect(harness.lines.count == 1)
|
|
||||||
#expect(!harness.lines[0].content.contains("2"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func markSeen_clearsOnlyVisibleScopes() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(geohash: ""))
|
|
||||||
model.handleArrival(makePost(geohash: "9q8yy"))
|
|
||||||
model.handleArrival(makePost(geohash: "u4pruyd"))
|
|
||||||
|
|
||||||
// Opening the sheet on mesh + 9q8yy must not eat the badge for the
|
|
||||||
// never-shown u4pruyd channel.
|
|
||||||
model.markSeen(forScopes: ["", "9q8yy"])
|
|
||||||
|
|
||||||
#expect(model.unseenCount(forGeohash: "") == 0)
|
|
||||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
|
||||||
#expect(model.unseenCount(forGeohash: "u4pruyd") == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func reset_dropsPendingUrgentLinesAndBadges() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(content: "pre-wipe secret", urgent: true))
|
|
||||||
#expect(harness.pendingFlushes.count == 1)
|
|
||||||
|
|
||||||
// Panic wipe lands before the collapse flush fires.
|
|
||||||
model.reset()
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(harness.lines.isEmpty)
|
|
||||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func longUrgentContent_isTruncatedInLine() {
|
|
||||||
let harness = Harness()
|
|
||||||
let model = makeModel(harness: harness)
|
|
||||||
let long = String(repeating: "a", count: 400)
|
|
||||||
|
|
||||||
model.handleArrival(makePost(content: long, urgent: true))
|
|
||||||
harness.flushAll()
|
|
||||||
|
|
||||||
#expect(harness.lines.count == 1)
|
|
||||||
#expect(harness.lines[0].content.count < 200)
|
|
||||||
#expect(harness.lines[0].content.contains("…"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -337,33 +337,6 @@ struct GatewayServiceTests {
|
|||||||
#expect(decoded.event()?.id == event.id)
|
#expect(decoded.event()?.id == event.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("never downlink-rebroadcasts an event it uplinked and saw echo back")
|
|
||||||
func uplinkedEventNotSelfEchoed() throws {
|
|
||||||
let fixture = Fixture()
|
|
||||||
let event = try makeEvent()
|
|
||||||
|
|
||||||
// A mesh-only peer deposits an event; the gateway uplinks (publishes)
|
|
||||||
// it to the relays (loop rule 2 records it in publishedEventIDs).
|
|
||||||
try deposit(event, into: fixture)
|
|
||||||
#expect(fixture.published.map(\.event.id) == [event.id])
|
|
||||||
|
|
||||||
// The gateway's own geohash subscription now delivers that same event
|
|
||||||
// right back (~0.15s later on device). It originated on this mesh, so
|
|
||||||
// rebroadcasting it would double BLE airtime — the device-confirmed
|
|
||||||
// self-echo. It must be suppressed.
|
|
||||||
fixture.service.rebroadcastRelayEvent(event, geohash: Self.geohash)
|
|
||||||
#expect(fixture.broadcasts.isEmpty)
|
|
||||||
|
|
||||||
// Sanity: a genuine inbound-from-internet event (never uplinked here)
|
|
||||||
// still downlink-rebroadcasts normally.
|
|
||||||
let inbound = try makeEvent()
|
|
||||||
fixture.service.rebroadcastRelayEvent(inbound, geohash: Self.geohash)
|
|
||||||
#expect(fixture.broadcasts.count == 1)
|
|
||||||
let payload = try #require(fixture.broadcasts.first)
|
|
||||||
let decoded = try #require(NostrCarrierPacket.decode(payload))
|
|
||||||
#expect(decoded.event()?.id == inbound.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("a forged broadcast cannot poison the loop-prevention set")
|
@Test("a forged broadcast cannot poison the loop-prevention set")
|
||||||
func forgedBroadcastDoesNotPoison() throws {
|
func forgedBroadcastDoesNotPoison() throws {
|
||||||
let fixture = Fixture()
|
let fixture = Fixture()
|
||||||
|
|||||||
@@ -277,14 +277,14 @@ private final class DiagnosticsMockContext: CommandContextProvider {
|
|||||||
func clearPrivateChat(_ peerID: PeerID) {}
|
func clearPrivateChat(_ peerID: PeerID) {}
|
||||||
func sendPublicRaw(_ content: String) {}
|
func sendPublicRaw(_ content: String) {}
|
||||||
func sendPublicMessage(_ content: String) {}
|
func sendPublicMessage(_ content: String) {}
|
||||||
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
||||||
|
func addPublicSystemMessage(_ content: String) {}
|
||||||
|
func toggleFavorite(peerID: PeerID) {}
|
||||||
func groupCreate(named name: String) -> CommandResult { .handled }
|
func groupCreate(named name: String) -> CommandResult { .handled }
|
||||||
func groupInvite(nickname: String) -> CommandResult { .handled }
|
func groupInvite(nickname: String) -> CommandResult { .handled }
|
||||||
func groupRemove(nickname: String) -> CommandResult { .handled }
|
func groupRemove(nickname: String) -> CommandResult { .handled }
|
||||||
func groupLeave() -> CommandResult { .handled }
|
func groupLeave() -> CommandResult { .handled }
|
||||||
func groupList() -> CommandResult { .handled }
|
func groupList() -> CommandResult { .handled }
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
|
||||||
func addPublicSystemMessage(_ content: String) {}
|
|
||||||
func toggleFavorite(peerID: PeerID) {}
|
|
||||||
|
|
||||||
func currentCommandDestination() -> CommandOutputDestination {
|
func currentCommandDestination() -> CommandOutputDestination {
|
||||||
if let peerID = selectedPrivateChatPeer {
|
if let peerID = selectedPrivateChatPeer {
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||||
permissionProvider: { permissionSubject.value },
|
permissionProvider: { permissionSubject.value },
|
||||||
mutualFavoritesProvider: { favoritesSubject.value },
|
mutualFavoritesProvider: { favoritesSubject.value },
|
||||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
import Combine
|
|
||||||
import XCTest
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
/// Covers the reachability-gate decision logic (pure debounce) and the
|
|
||||||
/// `NetworkActivationService` wiring that suppresses Tor/relay startup when
|
|
||||||
/// there is provably no network.
|
|
||||||
@MainActor
|
|
||||||
final class NetworkReachabilityGateTests: XCTestCase {
|
|
||||||
|
|
||||||
// MARK: - Pure debounce logic
|
|
||||||
|
|
||||||
func test_debounce_satisfiedStaysReachable() {
|
|
||||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
||||||
let t0 = Date()
|
|
||||||
// An interface remains present: no change, no pending.
|
|
||||||
XCTAssertNil(d.observe(reachable: true, at: t0))
|
|
||||||
XCTAssertTrue(d.committed)
|
|
||||||
XCTAssertFalse(d.hasPendingChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_debounce_unsatisfiedSuppressesAfterInterval() {
|
|
||||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
||||||
let t0 = Date()
|
|
||||||
// Path drops: not committed immediately (within debounce window).
|
|
||||||
XCTAssertNil(d.observe(reachable: false, at: t0))
|
|
||||||
XCTAssertTrue(d.committed)
|
|
||||||
XCTAssertTrue(d.hasPendingChange)
|
|
||||||
// Still within window.
|
|
||||||
XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0)))
|
|
||||||
XCTAssertTrue(d.committed)
|
|
||||||
// Past the window: commit unreachable.
|
|
||||||
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false)
|
|
||||||
XCTAssertFalse(d.committed)
|
|
||||||
XCTAssertFalse(d.hasPendingChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_debounce_flapIsIgnored() {
|
|
||||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
||||||
let t0 = Date()
|
|
||||||
// Drop then recover well within the window — must never commit a change.
|
|
||||||
XCTAssertNil(d.observe(reachable: false, at: t0))
|
|
||||||
XCTAssertTrue(d.hasPendingChange)
|
|
||||||
XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5)))
|
|
||||||
XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop")
|
|
||||||
// A late flush after the original deadline is a no-op (nothing pending).
|
|
||||||
XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0)))
|
|
||||||
XCTAssertTrue(d.committed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_debounce_recoverAfterOutageCommitsAfterInterval() {
|
|
||||||
var d = ReachabilityDebounce(interval: 2.5, initial: false)
|
|
||||||
let t0 = Date()
|
|
||||||
XCTAssertNil(d.observe(reachable: true, at: t0))
|
|
||||||
XCTAssertTrue(d.hasPendingChange)
|
|
||||||
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true)
|
|
||||||
XCTAssertTrue(d.committed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_debounce_duplicateObservationsPreservePendingDeadline() {
|
|
||||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
||||||
let t0 = Date()
|
|
||||||
XCTAssertNil(d.observe(reachable: false, at: t0))
|
|
||||||
// Duplicate unsatisfied updates mid-window keep the original deadline.
|
|
||||||
XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0)))
|
|
||||||
XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5)
|
|
||||||
// A duplicate arriving past the deadline commits immediately.
|
|
||||||
XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false)
|
|
||||||
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
|
|
||||||
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
|
|
||||||
var received: [Bool] = []
|
|
||||||
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
|
||||||
defer { cancellable.cancel() }
|
|
||||||
|
|
||||||
let start = Date()
|
|
||||||
monitor.ingest(reachable: false)
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
// Duplicate unsatisfied update mid-window (e.g. interface detail change
|
|
||||||
// while still offline) must not restart the debounce window.
|
|
||||||
monitor.ingest(reachable: false)
|
|
||||||
|
|
||||||
let committed = await waitUntil(timeout: 2.0) { !received.isEmpty }
|
|
||||||
XCTAssertTrue(committed)
|
|
||||||
XCTAssertEqual(received, [false])
|
|
||||||
// The flush must fire at the original ~1.0s deadline, not ~1.5s
|
|
||||||
// (a full interval after the duplicate).
|
|
||||||
XCTAssertLessThan(Date().timeIntervalSince(start), 1.4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Service gating
|
|
||||||
|
|
||||||
func test_start_whenUnreachable_suppressesTorAndRelays() {
|
|
||||||
let ctx = makeService(permission: .authorized, reachable: false)
|
|
||||||
ctx.service.start()
|
|
||||||
|
|
||||||
XCTAssertFalse(ctx.service.activationAllowed)
|
|
||||||
XCTAssertFalse(ctx.service.isNetworkReachable)
|
|
||||||
XCTAssertTrue(ctx.reachability.startCalled)
|
|
||||||
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0)
|
|
||||||
XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false])
|
|
||||||
XCTAssertEqual(ctx.relayController.connectCallCount, 0)
|
|
||||||
XCTAssertEqual(ctx.relayController.disconnectCallCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_start_whenReachable_allowsTorAndRelays() {
|
|
||||||
let ctx = makeService(permission: .authorized, reachable: true)
|
|
||||||
ctx.service.start()
|
|
||||||
|
|
||||||
XCTAssertTrue(ctx.service.activationAllowed)
|
|
||||||
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1)
|
|
||||||
XCTAssertEqual(ctx.relayController.connectCallCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_reachabilityRecovery_resumesTorAndRelays() async {
|
|
||||||
let ctx = makeService(permission: .authorized, reachable: false)
|
|
||||||
ctx.service.start()
|
|
||||||
XCTAssertFalse(ctx.service.activationAllowed)
|
|
||||||
|
|
||||||
ctx.reachability.set(true)
|
|
||||||
let resumed = await waitUntil { ctx.service.activationAllowed }
|
|
||||||
XCTAssertTrue(resumed)
|
|
||||||
XCTAssertTrue(ctx.service.isNetworkReachable)
|
|
||||||
XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1)
|
|
||||||
XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async {
|
|
||||||
let ctx = makeService(permission: .authorized, reachable: true)
|
|
||||||
ctx.service.start()
|
|
||||||
XCTAssertTrue(ctx.service.activationAllowed)
|
|
||||||
let disconnectsBefore = ctx.relayController.disconnectCallCount
|
|
||||||
|
|
||||||
ctx.reachability.set(false)
|
|
||||||
let suppressed = await waitUntil { !ctx.service.activationAllowed }
|
|
||||||
XCTAssertTrue(suppressed)
|
|
||||||
XCTAssertFalse(ctx.service.isNetworkReachable)
|
|
||||||
XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore)
|
|
||||||
XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false))
|
|
||||||
XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Harness
|
|
||||||
|
|
||||||
private func makeService(
|
|
||||||
permission: LocationChannelManager.PermissionState,
|
|
||||||
reachable: Bool
|
|
||||||
) -> Context {
|
|
||||||
let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)"
|
|
||||||
let storage = UserDefaults(suiteName: suiteName)!
|
|
||||||
storage.removePersistentDomain(forName: suiteName)
|
|
||||||
|
|
||||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
|
||||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>([])
|
|
||||||
let reachability = ControllableReachabilityMonitor(initial: reachable)
|
|
||||||
let torController = GateMockTorController()
|
|
||||||
let relayController = GateMockRelayController()
|
|
||||||
let proxyController = GateMockProxyController()
|
|
||||||
let service = NetworkActivationService(
|
|
||||||
storage: storage,
|
|
||||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
|
||||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
|
||||||
permissionProvider: { permissionSubject.value },
|
|
||||||
mutualFavoritesProvider: { favoritesSubject.value },
|
|
||||||
reachabilityMonitor: reachability,
|
|
||||||
torController: torController,
|
|
||||||
relayController: relayController,
|
|
||||||
proxyController: proxyController,
|
|
||||||
notificationCenter: NotificationCenter()
|
|
||||||
)
|
|
||||||
return Context(
|
|
||||||
service: service,
|
|
||||||
reachability: reachability,
|
|
||||||
torController: torController,
|
|
||||||
relayController: relayController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitUntil(
|
|
||||||
timeout: TimeInterval = 1.0,
|
|
||||||
condition: @escaping @MainActor () -> Bool
|
|
||||||
) async -> Bool {
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
while Date() < deadline {
|
|
||||||
if condition() { return true }
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
}
|
|
||||||
return condition()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private struct Context {
|
|
||||||
let service: NetworkActivationService
|
|
||||||
let reachability: ControllableReachabilityMonitor
|
|
||||||
let torController: GateMockTorController
|
|
||||||
let relayController: GateMockRelayController
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring {
|
|
||||||
private let subject: CurrentValueSubject<Bool, Never>
|
|
||||||
private(set) var startCalled = false
|
|
||||||
|
|
||||||
init(initial: Bool) {
|
|
||||||
subject = CurrentValueSubject(initial)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isReachable: Bool { subject.value }
|
|
||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
|
||||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func start() { startCalled = true }
|
|
||||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class GateMockTorController: NetworkActivationTorControlling {
|
|
||||||
private(set) var autoStartAllowedValues: [Bool] = []
|
|
||||||
private(set) var startIfNeededCallCount = 0
|
|
||||||
private(set) var shutdownCompletelyCallCount = 0
|
|
||||||
func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) }
|
|
||||||
func startIfNeeded() { startIfNeededCallCount += 1 }
|
|
||||||
func shutdownCompletely() { shutdownCompletelyCallCount += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class GateMockRelayController: NetworkActivationRelayControlling {
|
|
||||||
private(set) var connectCallCount = 0
|
|
||||||
private(set) var disconnectCallCount = 0
|
|
||||||
func connect() { connectCallCount += 1 }
|
|
||||||
func disconnect() { disconnectCallCount += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class GateMockProxyController: NetworkActivationProxyControlling {
|
|
||||||
private(set) var proxyModes: [Bool] = []
|
|
||||||
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
//
|
|
||||||
// UnifiedNoticesTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
struct UnifiedNoticesTests {
|
|
||||||
|
|
||||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
|
||||||
|
|
||||||
private func makePost(
|
|
||||||
content: String,
|
|
||||||
nickname: String = "alice",
|
|
||||||
createdAt: UInt64? = nil,
|
|
||||||
urgent: Bool = false
|
|
||||||
) -> BoardPostPacket {
|
|
||||||
BoardPostPacket(
|
|
||||||
postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
|
|
||||||
geohash: "9q8yy",
|
|
||||||
content: content,
|
|
||||||
authorSigningKey: Data(repeating: 1, count: 32),
|
|
||||||
authorNickname: nickname,
|
|
||||||
createdAt: createdAt ?? baseMs,
|
|
||||||
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
|
|
||||||
flags: urgent ? BoardPostPacket.urgentFlag : 0,
|
|
||||||
signature: Data(repeating: 2, count: 64)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeNote(
|
|
||||||
content: String,
|
|
||||||
nickname: String? = "alice",
|
|
||||||
createdAt: Date? = nil,
|
|
||||||
geohash: String = "9q8yy"
|
|
||||||
) -> LocationNotesManager.Note {
|
|
||||||
LocationNotesManager.Note(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""),
|
|
||||||
content: content,
|
|
||||||
createdAt: createdAt ?? baseDate,
|
|
||||||
nickname: nickname,
|
|
||||||
geohash: geohash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func merge_dropsBridgedCopyOfBoardPost() {
|
|
||||||
let post = makePost(content: "free couch on 5th")
|
|
||||||
let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30))
|
|
||||||
|
|
||||||
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
|
|
||||||
|
|
||||||
#expect(merged.count == 1)
|
|
||||||
#expect(merged[0].isBoardPost)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func merge_keepsNoteWithSameContentOutsideWindow() {
|
|
||||||
let post = makePost(content: "water station here")
|
|
||||||
let oldNote = makeNote(
|
|
||||||
content: "water station here",
|
|
||||||
createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60)
|
|
||||||
)
|
|
||||||
|
|
||||||
let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote])
|
|
||||||
|
|
||||||
#expect(merged.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func merge_keepsSameTextNoteFromNeighborCell() {
|
|
||||||
// The notes subscription covers the center cell plus 8 neighbors; a
|
|
||||||
// matching note posted to a *neighbor* is not the bridged copy.
|
|
||||||
let post = makePost(content: "free couch on 5th")
|
|
||||||
let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz")
|
|
||||||
|
|
||||||
let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote])
|
|
||||||
|
|
||||||
#expect(merged.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func merge_keepsNoteFromDifferentAuthor() {
|
|
||||||
let post = makePost(content: "meetup at 6", nickname: "alice")
|
|
||||||
let note = makeNote(content: "meetup at 6", nickname: "bob")
|
|
||||||
|
|
||||||
let merged = UnifiedNotices.merge(posts: [post], notes: [note])
|
|
||||||
|
|
||||||
#expect(merged.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func merge_sortsUrgentFirstThenNewest() {
|
|
||||||
let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true)
|
|
||||||
let newerPost = makePost(content: "later post", createdAt: baseMs)
|
|
||||||
let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30))
|
|
||||||
|
|
||||||
let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note])
|
|
||||||
|
|
||||||
#expect(merged.map(\.content) == ["road closed", "a note", "later post"])
|
|
||||||
#expect(merged[0].isUrgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func merge_anonNicknamesMatchForDedupe() {
|
|
||||||
// Bridged posts from an empty nickname arrive as anon notes with no
|
|
||||||
// "n" tag; they must still dedupe against the anon board copy.
|
|
||||||
let post = makePost(content: "hello", nickname: "")
|
|
||||||
let bridged = makeNote(content: "hello", nickname: nil)
|
|
||||||
|
|
||||||
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
|
|
||||||
|
|
||||||
#expect(merged.count == 1)
|
|
||||||
#expect(merged[0].isBoardPost)
|
|
||||||
#expect(merged[0].author == "anon")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func noticeItem_normalizesNoteDisplayName() {
|
|
||||||
let note = LocationNotesManager.Note(
|
|
||||||
id: "e1",
|
|
||||||
pubkey: "deadbeef",
|
|
||||||
content: "hi",
|
|
||||||
createdAt: baseDate,
|
|
||||||
nickname: "dave",
|
|
||||||
geohash: "9q8yy"
|
|
||||||
)
|
|
||||||
|
|
||||||
let item = NoticeItem(note: note)
|
|
||||||
|
|
||||||
#expect(item.author == "dave")
|
|
||||||
#expect(!item.isBoardPost)
|
|
||||||
#expect(!item.isUrgent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,8 @@ struct GossipSyncBoardTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
// Silence the prekey round (added by the prekeys feature; groupMessage
|
||||||
|
// rides the message schedule, already off) so board is isolated.
|
||||||
config.prekeyBundleSyncIntervalSeconds = 0
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ struct SyncTypeFlagsBoardTests {
|
|||||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||||
/// message type, so a board-only request reads as "nothing I can serve".
|
/// message type, so a board-only request reads as "nothing I can serve".
|
||||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||||
// Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle,
|
// Bits 11-15 are unassigned after the feature integration (bit 8 is
|
||||||
// bit 10 = groupMessage); a future (or unknown) two-byte bitfield must
|
// board, 9 prekey, 10 group); a future (or unknown) two-byte bitfield
|
||||||
// decode without error and yield no known types.
|
// must decode without error and yield no known types.
|
||||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
|
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
|
||||||
#expect(decoded.toMessageTypes().isEmpty)
|
#expect(decoded.toMessageTypes().isEmpty)
|
||||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] {
|
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||||
#expect(!decoded.contains(type))
|
#expect(!decoded.contains(type))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,43 +13,33 @@ struct SyncTypeFlagsTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func decodeDropsPhantomBits() {
|
@Test func decodeDropsPhantomBits() {
|
||||||
// Bits 11+ map to no message type (bit 8 = boardPost, bit 9 =
|
// Bits 11+ map to no message type (bits 8/9/10 are board/prekey/group
|
||||||
// prekeyBundle, bit 10 = groupMessage). They must not survive decode
|
// after the feature integration). They must not survive decode as
|
||||||
// as phantom membership.
|
// phantom membership.
|
||||||
let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type
|
let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type
|
||||||
let decoded = SyncTypeFlags.decode(phantom)
|
let decoded = SyncTypeFlags.decode(phantom)
|
||||||
#expect(decoded?.rawValue == 0)
|
#expect(decoded?.rawValue == 0)
|
||||||
#expect(decoded?.toMessageTypes().isEmpty == true)
|
#expect(decoded?.toMessageTypes().isEmpty == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func boardBitSurvivesDecode() {
|
|
||||||
// Bit 8 maps to boardPost and spills the field into a second byte;
|
|
||||||
// it must survive decode while the phantom high bits (11+) are
|
|
||||||
// stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared
|
|
||||||
// to isolate the board bit.
|
|
||||||
let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom
|
|
||||||
let decoded = SyncTypeFlags.decode(mixed)
|
|
||||||
#expect(decoded?.contains(.board) == true)
|
|
||||||
#expect(decoded?.rawValue == 0b1_0000_0000)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func phantomBitsAreStrippedButKnownBitsSurvive() {
|
@Test func phantomBitsAreStrippedButKnownBitsSurvive() {
|
||||||
// Low byte = announce(0) + message(1); high byte bits 11+ are phantom.
|
// Low byte = announce(0) + message(1); high byte = phantom (bits 11+,
|
||||||
|
// which map to no known type).
|
||||||
let mixed = Data([0b0000_0011, 0xF8])
|
let mixed = Data([0b0000_0011, 0xF8])
|
||||||
let decoded = SyncTypeFlags.decode(mixed)
|
let decoded = SyncTypeFlags.decode(mixed)
|
||||||
#expect(decoded?.contains(.announce) == true)
|
#expect(decoded?.contains(.announce) == true)
|
||||||
#expect(decoded?.contains(.message) == true)
|
#expect(decoded?.contains(.message) == true)
|
||||||
// Only the two known bits remain; phantom high bits are gone.
|
// Only the two known bits remain; phantom high byte is gone.
|
||||||
#expect(decoded?.rawValue == 0b0000_0011)
|
#expect(decoded?.rawValue == 0b0000_0011)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func rawValueInitNormalizesPhantomBits() {
|
@Test func rawValueInitNormalizesPhantomBits() {
|
||||||
let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF)
|
let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF)
|
||||||
// Every known type bit is set; nothing above them survives. boardPost
|
// Every known type bit is set; nothing above them survives. The
|
||||||
// occupies bit 8, so the known set spills into a second byte.
|
// highest known bit is 10 (groupMessage), so the field serializes to
|
||||||
|
// two bytes.
|
||||||
#expect(flags.contains(.announce))
|
#expect(flags.contains(.announce))
|
||||||
#expect(flags.contains(.fileTransfer))
|
#expect(flags.contains(.fileTransfer))
|
||||||
#expect(flags.contains(.board))
|
|
||||||
let data = flags.toData()
|
let data = flags.toData()
|
||||||
#expect(data?.count == 2)
|
#expect(data?.count == 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Combine
|
|
||||||
import Testing
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -40,7 +39,6 @@ private struct SmokeFeatureModels {
|
|||||||
let verificationModel: VerificationModel
|
let verificationModel: VerificationModel
|
||||||
let conversationUIModel: ConversationUIModel
|
let conversationUIModel: ConversationUIModel
|
||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let boardAlertsModel: BoardAlertsModel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -82,14 +80,6 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
|||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
|
|
||||||
let boardAlertsModel = BoardAlertsModel(
|
|
||||||
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
|
|
||||||
dependencies: BoardAlertsModel.Dependencies(
|
|
||||||
isOwnPost: { _ in false },
|
|
||||||
emitSystemLine: { _, _ in }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return SmokeFeatureModels(
|
return SmokeFeatureModels(
|
||||||
publicChatModel: publicChatModel,
|
publicChatModel: publicChatModel,
|
||||||
appChromeModel: appChromeModel,
|
appChromeModel: appChromeModel,
|
||||||
@@ -98,8 +88,7 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
|||||||
privateConversationModel: privateConversationModel,
|
privateConversationModel: privateConversationModel,
|
||||||
verificationModel: verificationModel,
|
verificationModel: verificationModel,
|
||||||
conversationUIModel: conversationUIModel,
|
conversationUIModel: conversationUIModel,
|
||||||
peerListModel: peerListModel,
|
peerListModel: peerListModel
|
||||||
boardAlertsModel: boardAlertsModel
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +106,6 @@ private func installSmokeEnvironment<V: View>(
|
|||||||
.environmentObject(featureModels.verificationModel)
|
.environmentObject(featureModels.verificationModel)
|
||||||
.environmentObject(featureModels.conversationUIModel)
|
.environmentObject(featureModels.conversationUIModel)
|
||||||
.environmentObject(featureModels.peerListModel)
|
.environmentObject(featureModels.peerListModel)
|
||||||
.environmentObject(featureModels.boardAlertsModel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -407,17 +395,9 @@ struct ViewSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func noticesView_rendersNoRelayAndLoadedStates() throws {
|
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
||||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq")))
|
|
||||||
defer { featureModels.locationChannelsModel.select(.mesh) }
|
|
||||||
let board = BoardManager(
|
|
||||||
transport: transport,
|
|
||||||
store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
|
|
||||||
publishToNostr: { _, _, _, _ in nil },
|
|
||||||
deleteFromNostr: { _, _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let noRelayManager = LocationNotesManager(
|
let noRelayManager = LocationNotesManager(
|
||||||
geohash: "u4pruydq",
|
geohash: "u4pruydq",
|
||||||
@@ -460,28 +440,18 @@ struct ViewSmokeTests {
|
|||||||
eose?()
|
eose?()
|
||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
NoticesView(
|
LocationNotesView(
|
||||||
|
geohash: "u4pruydq",
|
||||||
senderNickname: viewModel.nickname,
|
senderNickname: viewModel.nickname,
|
||||||
board: board,
|
manager: noRelayManager
|
||||||
initialTab: .geo,
|
|
||||||
notesManager: noRelayManager
|
|
||||||
)
|
)
|
||||||
.environmentObject(featureModels.locationChannelsModel)
|
.environmentObject(featureModels.locationChannelsModel)
|
||||||
)
|
)
|
||||||
_ = mount(
|
_ = mount(
|
||||||
NoticesView(
|
LocationNotesView(
|
||||||
|
geohash: "u4pruydq",
|
||||||
senderNickname: viewModel.nickname,
|
senderNickname: viewModel.nickname,
|
||||||
board: board,
|
manager: loadedManager
|
||||||
initialTab: .geo,
|
|
||||||
notesManager: loadedManager
|
|
||||||
)
|
|
||||||
.environmentObject(featureModels.locationChannelsModel)
|
|
||||||
)
|
|
||||||
_ = mount(
|
|
||||||
NoticesView(
|
|
||||||
senderNickname: viewModel.nickname,
|
|
||||||
board: board,
|
|
||||||
initialTab: .mesh
|
|
||||||
)
|
)
|
||||||
.environmentObject(featureModels.locationChannelsModel)
|
.environmentObject(featureModels.locationChannelsModel)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkCryptoTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("WifiBulk channel crypto")
|
||||||
|
struct WifiBulkCryptoTests {
|
||||||
|
private static func keyHex(_ key: SymmetricKey) -> String {
|
||||||
|
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeKey() throws -> SymmetricKey {
|
||||||
|
try #require(WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: Data(repeating: 0x11, count: 32),
|
||||||
|
receiverToken: Data(repeating: 0x22, count: 32),
|
||||||
|
transferID: Data(repeating: 0x33, count: 16)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: HKDF derivation
|
||||||
|
|
||||||
|
@Test("HKDF derivation matches fixed vectors")
|
||||||
|
func hkdfVectors() throws {
|
||||||
|
// Vectors computed independently with CryptoKit HKDF<SHA256>,
|
||||||
|
// ikm = senderToken ‖ receiverToken, salt = transferID,
|
||||||
|
// info = "bitchat-bulk-v1", 32 bytes out.
|
||||||
|
let key1 = try makeKey()
|
||||||
|
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
|
||||||
|
|
||||||
|
let key2 = try #require(WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: Data((0..<32).map { UInt8($0) }),
|
||||||
|
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
|
||||||
|
transferID: Data((0..<16).map { UInt8($0 * 3) })
|
||||||
|
))
|
||||||
|
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("HKDF derivation is order- and role-sensitive")
|
||||||
|
func hkdfRoleSensitivity() throws {
|
||||||
|
let a = Data(repeating: 0x11, count: 32)
|
||||||
|
let b = Data(repeating: 0x22, count: 32)
|
||||||
|
let tid = Data(repeating: 0x33, count: 16)
|
||||||
|
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
|
||||||
|
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
|
||||||
|
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("HKDF derivation rejects wrong-length inputs")
|
||||||
|
func hkdfRejectsBadLengths() {
|
||||||
|
let token = Data(repeating: 1, count: 32)
|
||||||
|
let tid = Data(repeating: 2, count: 16)
|
||||||
|
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
|
||||||
|
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
|
||||||
|
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame sealing
|
||||||
|
|
||||||
|
@Test("Frame seal/open round-trips")
|
||||||
|
func frameRoundTrip() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
|
||||||
|
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
|
||||||
|
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
|
||||||
|
#expect(opened == plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tampered frames are rejected")
|
||||||
|
func tamperRejection() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
body[body.count - 1] ^= 0x01 // flip a tag bit
|
||||||
|
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frames cannot be replayed at another counter or reflected across directions")
|
||||||
|
func nonceBinding() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
|
||||||
|
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
|
||||||
|
}
|
||||||
|
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frames sealed under a different key are rejected")
|
||||||
|
func wrongKeyRejection() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let otherKey = try #require(WifiBulkCrypto.deriveKey(
|
||||||
|
senderToken: Data(repeating: 0x44, count: 32),
|
||||||
|
receiverToken: Data(repeating: 0x22, count: 32),
|
||||||
|
transferID: Data(repeating: 0x33, count: 16)
|
||||||
|
))
|
||||||
|
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
|
||||||
|
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
|
||||||
|
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Auth and receipt control frames validate and reject forgeries")
|
||||||
|
func controlFrames() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let transferID = Data(repeating: 0x33, count: 16)
|
||||||
|
let hash = Data(repeating: 0x55, count: 32)
|
||||||
|
|
||||||
|
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||||
|
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
|
||||||
|
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
|
||||||
|
var forgedAuth = auth
|
||||||
|
forgedAuth[forgedAuth.count - 1] ^= 0x01
|
||||||
|
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
|
||||||
|
|
||||||
|
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
|
||||||
|
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
|
||||||
|
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
|
||||||
|
// An auth frame is not a receipt (distinct counter).
|
||||||
|
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Frame buffer
|
||||||
|
|
||||||
|
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
|
||||||
|
func frameBufferReassembly() throws {
|
||||||
|
let bodyA = Data(repeating: 0xAA, count: 100)
|
||||||
|
let bodyB = Data(repeating: 0xBB, count: 5)
|
||||||
|
var stream = WifiBulkCrypto.frameData(body: bodyA)
|
||||||
|
stream.append(WifiBulkCrypto.frameData(body: bodyB))
|
||||||
|
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
|
||||||
|
// Drip-feed 3 bytes at a time.
|
||||||
|
var extracted: [Data] = []
|
||||||
|
var index = stream.startIndex
|
||||||
|
while index < stream.endIndex {
|
||||||
|
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
|
||||||
|
buffer.append(Data(stream[index..<next]))
|
||||||
|
while let body = try buffer.nextFrameBody() {
|
||||||
|
extracted.append(body)
|
||||||
|
}
|
||||||
|
index = next
|
||||||
|
}
|
||||||
|
#expect(extracted == [bodyA, bodyB])
|
||||||
|
#expect(try buffer.nextFrameBody() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frame buffer rejects oversized frame lengths without buffering them")
|
||||||
|
func frameBufferOversizeRejection() {
|
||||||
|
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
|
||||||
|
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
|
||||||
|
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
|
||||||
|
_ = try buffer.nextFrameBody()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Payload assembler
|
||||||
|
|
||||||
|
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
|
||||||
|
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
|
||||||
|
try WifiBulkCrypto.sealFrameBody(
|
||||||
|
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
|
||||||
|
direction: .senderToReceiver,
|
||||||
|
counter: UInt64(index),
|
||||||
|
key: key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler reassembles and verifies a chunked payload")
|
||||||
|
func assemblerHappyPath() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(payload.count),
|
||||||
|
expectedHash: Data(SHA256.hash(data: payload)),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
|
||||||
|
var result: Data?
|
||||||
|
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
|
||||||
|
result = try assembler.consume(frameBody: chunk)
|
||||||
|
}
|
||||||
|
#expect(result == payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler rejects a payload whose final hash mismatches the offer")
|
||||||
|
func assemblerHashMismatch() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data(repeating: 0x77, count: 100_000)
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(payload.count),
|
||||||
|
expectedHash: Data(repeating: 0, count: 32), // wrong hash
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
|
||||||
|
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
|
||||||
|
_ = try assembler.consume(frameBody: chunks[0])
|
||||||
|
#expect(throws: WifiBulkCryptoError.hashMismatch) {
|
||||||
|
_ = try assembler.consume(frameBody: chunks[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler rejects overflow beyond the offered size")
|
||||||
|
func assemblerOverflow() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data(repeating: 0x77, count: 1000)
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: 500, // offer promised less than the sender streams
|
||||||
|
expectedHash: Data(SHA256.hash(data: payload)),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
|
||||||
|
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
|
||||||
|
_ = try assembler.consume(frameBody: chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler enforces the receiver-side size cap at construction")
|
||||||
|
func assemblerSizeCap() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
#expect(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
|
||||||
|
expectedHash: Data(repeating: 0, count: 32),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) == nil)
|
||||||
|
#expect(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: 0,
|
||||||
|
expectedHash: Data(repeating: 0, count: 32),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Assembler rejects out-of-order chunks")
|
||||||
|
func assemblerOutOfOrder() throws {
|
||||||
|
let key = try makeKey()
|
||||||
|
let payload = Data(repeating: 0x42, count: 100_000)
|
||||||
|
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||||
|
key: key,
|
||||||
|
expectedSize: UInt64(payload.count),
|
||||||
|
expectedHash: Data(SHA256.hash(data: payload)),
|
||||||
|
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||||
|
))
|
||||||
|
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
|
||||||
|
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||||
|
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkLoopbackTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// End-to-end integration over real Network.framework sockets on localhost:
|
||||||
|
/// two `WifiBulkTransferService` instances negotiate through an in-process
|
||||||
|
/// "Noise" pipe, then move the payload over a genuine TCP connection using
|
||||||
|
/// the production listener/browser-free test hooks (peer-to-peer and Bonjour
|
||||||
|
/// are disabled — unit-test hosts have no AWDL or mDNS access).
|
||||||
|
@Suite("WifiBulk loopback integration", .serialized)
|
||||||
|
struct WifiBulkLoopbackTests {
|
||||||
|
private static let senderPeer = PeerID(str: "00112233aabbccdd")
|
||||||
|
private static let receiverPeer = PeerID(str: "ddccbbaa33221100")
|
||||||
|
|
||||||
|
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||||
|
var config = WifiBulkTransferServiceConfig()
|
||||||
|
config.usePeerToPeer = false
|
||||||
|
config.publishBonjourService = false
|
||||||
|
config.offerTimeout = 5.0
|
||||||
|
config.transferWindow = 10.0
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Payload crosses a real TCP loopback channel and lands verified")
|
||||||
|
func loopbackTransferSucceeds() async throws {
|
||||||
|
let payload = Data((0..<300_000).map { UInt8($0 % 249) })
|
||||||
|
|
||||||
|
let delivered = WifiBulkTestBox<Data>()
|
||||||
|
let progress = WifiBulkTestBox<String>()
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||||
|
let offers = WifiBulkTestBox<Data>()
|
||||||
|
|
||||||
|
var senderService: WifiBulkTransferService?
|
||||||
|
var receiverService: WifiBulkTransferService?
|
||||||
|
|
||||||
|
// Once the receiver has accepted AND the listener port is known,
|
||||||
|
// connect the receiver straight to 127.0.0.1 (Bonjour stand-in).
|
||||||
|
let accepted = WifiBulkTestBox<Data>()
|
||||||
|
let connectIfReady: () -> Void = {
|
||||||
|
guard let (transferID, port) = ports.values.first,
|
||||||
|
accepted.values.contains(transferID),
|
||||||
|
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||||
|
receiverService?._test_connectIncoming(
|
||||||
|
transferID: transferID,
|
||||||
|
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
// Sender → receiver control plane (offer).
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||||
|
offers.append(Data(typed.dropFirst()))
|
||||||
|
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { _, _, _ in },
|
||||||
|
progressStart: { id, total in progress.append("start:\(id):\(total)") },
|
||||||
|
progressChunkSent: { id in progress.append("chunk:\(id)") },
|
||||||
|
progressReset: { id in progress.append("reset:\(id)") },
|
||||||
|
progressCancel: { id in progress.append("cancel:\(id)") }
|
||||||
|
)
|
||||||
|
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
// Receiver → sender control plane (response).
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||||
|
let body = Data(typed.dropFirst())
|
||||||
|
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||||
|
accepted.append(response.transferID)
|
||||||
|
}
|
||||||
|
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||||
|
connectIfReady()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { data, peer, limit in
|
||||||
|
#expect(peer == Self.senderPeer)
|
||||||
|
#expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes)
|
||||||
|
delivered.append(data)
|
||||||
|
},
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||||
|
sender._test_onListenerReady = { transferID, port in
|
||||||
|
ports.append((transferID, port))
|
||||||
|
connectIfReady()
|
||||||
|
}
|
||||||
|
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||||
|
senderService = sender
|
||||||
|
receiverService = receiver
|
||||||
|
|
||||||
|
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||||
|
#expect(delivered.values.first == payload)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
|
||||||
|
// Deterministic teardown: both sides drop all transfer state.
|
||||||
|
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||||
|
#expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 })
|
||||||
|
|
||||||
|
// Progress mirrored the BLE contract: start with the chunk total,
|
||||||
|
// then exactly `total` chunk ticks (the last one gated on the receipt).
|
||||||
|
let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes
|
||||||
|
#expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks })
|
||||||
|
#expect(progress.values.first == "start:t-loopback:\(totalChunks)")
|
||||||
|
#expect(!progress.values.contains("reset:t-loopback"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds")
|
||||||
|
func gatecrasherIsRejected() async throws {
|
||||||
|
let payload = Data((0..<150_000).map { UInt8($0 % 241) })
|
||||||
|
|
||||||
|
let delivered = WifiBulkTestBox<Data>()
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||||
|
let accepted = WifiBulkTestBox<Data>()
|
||||||
|
|
||||||
|
var senderService: WifiBulkTransferService?
|
||||||
|
var receiverService: WifiBulkTransferService?
|
||||||
|
|
||||||
|
let gatecrashed = WifiBulkTestBox<Bool>()
|
||||||
|
let connectIfReady: () -> Void = {
|
||||||
|
guard let (transferID, port) = ports.values.first,
|
||||||
|
accepted.values.contains(transferID),
|
||||||
|
gatecrashed.count > 0,
|
||||||
|
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||||
|
receiverService?._test_connectIncoming(
|
||||||
|
transferID: transferID,
|
||||||
|
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||||
|
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { _, _, _ in },
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { typed, _ in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||||
|
let body = Data(typed.dropFirst())
|
||||||
|
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||||
|
accepted.append(response.transferID)
|
||||||
|
}
|
||||||
|
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||||
|
connectIfReady()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { data, _, _ in delivered.append(data) },
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||||
|
let gateQueue = DispatchQueue(label: "test.gatecrasher")
|
||||||
|
sender._test_onListenerReady = { transferID, port in
|
||||||
|
ports.append((transferID, port))
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||||
|
// A stranger who saw the Bonjour advertisement connects first and
|
||||||
|
// sends garbage that cannot carry a valid MAC.
|
||||||
|
let crasher = NWConnection(
|
||||||
|
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort),
|
||||||
|
using: .tcp
|
||||||
|
)
|
||||||
|
crasher.stateUpdateHandler = { state in
|
||||||
|
if case .ready = state {
|
||||||
|
let junkBody = Data(repeating: 0xAA, count: 60)
|
||||||
|
crasher.send(
|
||||||
|
content: WifiBulkCrypto.frameData(body: junkBody),
|
||||||
|
completion: .contentProcessed { _ in
|
||||||
|
gatecrashed.append(true)
|
||||||
|
connectIfReady()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crasher.start(queue: gateQueue)
|
||||||
|
}
|
||||||
|
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||||
|
senderService = sender
|
||||||
|
receiverService = receiver
|
||||||
|
|
||||||
|
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||||
|
#expect(delivered.values.first == payload)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE")
|
||||||
|
func vanishedReceiverFallsBack() async {
|
||||||
|
var config = makeConfig()
|
||||||
|
config.offerTimeout = 0.3
|
||||||
|
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let environment = WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: { _, _, _ in },
|
||||||
|
progressStart: { _, _ in },
|
||||||
|
progressChunkSent: { _ in },
|
||||||
|
progressReset: { _ in },
|
||||||
|
progressCancel: { _ in }
|
||||||
|
)
|
||||||
|
let sender = WifiBulkTransferService(environment: environment, config: config)
|
||||||
|
sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
#expect(sender._test_activeOutgoingCount == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkMessagesTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("WifiBulk offer/response TLV")
|
||||||
|
struct WifiBulkMessagesTests {
|
||||||
|
private func makeOffer(
|
||||||
|
fileSize: UInt64 = 300_000,
|
||||||
|
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
|
||||||
|
) -> WifiBulkOffer {
|
||||||
|
WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
|
||||||
|
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
|
||||||
|
serviceName: serviceName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer round-trips through TLV encoding")
|
||||||
|
func offerRoundTrip() throws {
|
||||||
|
let offer = makeOffer()
|
||||||
|
let encoded = try #require(offer.encode())
|
||||||
|
let decoded = try #require(WifiBulkOffer.decode(encoded))
|
||||||
|
#expect(decoded == offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer encode rejects malformed field lengths")
|
||||||
|
func offerEncodeRejectsBadFields() {
|
||||||
|
#expect(WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 1, count: 15), // short transferID
|
||||||
|
fileSize: 1,
|
||||||
|
payloadHash: Data(repeating: 2, count: 32),
|
||||||
|
token: Data(repeating: 3, count: 32),
|
||||||
|
serviceName: "x"
|
||||||
|
).encode() == nil)
|
||||||
|
|
||||||
|
#expect(makeOffer(serviceName: "").encode() == nil)
|
||||||
|
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer decode rejects missing or wrong-length fields")
|
||||||
|
func offerDecodeRejectsMalformed() throws {
|
||||||
|
let encoded = try #require(makeOffer().encode())
|
||||||
|
|
||||||
|
// Truncation anywhere breaks a TLV boundary or drops a required field.
|
||||||
|
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
|
||||||
|
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
|
||||||
|
#expect(WifiBulkOffer.decode(Data()) == nil)
|
||||||
|
|
||||||
|
// A wrong-length transferID TLV is ignored, leaving the field missing.
|
||||||
|
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
|
||||||
|
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
|
||||||
|
#expect(WifiBulkOffer.decode(mangled) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer decode skips unknown TLVs for forward compatibility")
|
||||||
|
func offerDecodeSkipsUnknownTLVs() throws {
|
||||||
|
var encoded = try #require(makeOffer().encode())
|
||||||
|
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
|
||||||
|
let decoded = try #require(WifiBulkOffer.decode(encoded))
|
||||||
|
#expect(decoded == makeOffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Accept response round-trips with token")
|
||||||
|
func acceptResponseRoundTrip() throws {
|
||||||
|
let response = WifiBulkResponse.accept(
|
||||||
|
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
|
||||||
|
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
|
||||||
|
)
|
||||||
|
let encoded = try #require(response.encode())
|
||||||
|
let decoded = try #require(WifiBulkResponse.decode(encoded))
|
||||||
|
#expect(decoded == response)
|
||||||
|
#expect(decoded.accepted)
|
||||||
|
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Decline response round-trips without token")
|
||||||
|
func declineResponseRoundTrip() throws {
|
||||||
|
let response = WifiBulkResponse.decline(
|
||||||
|
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
|
||||||
|
)
|
||||||
|
let encoded = try #require(response.encode())
|
||||||
|
let decoded = try #require(WifiBulkResponse.decode(encoded))
|
||||||
|
#expect(decoded == response)
|
||||||
|
#expect(!decoded.accepted)
|
||||||
|
#expect(decoded.token == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Accept response without a token is rejected")
|
||||||
|
func acceptWithoutTokenRejected() throws {
|
||||||
|
// Hand-build: transferID + accepted=1, no token TLV.
|
||||||
|
var data = Data()
|
||||||
|
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
|
||||||
|
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
|
||||||
|
#expect(WifiBulkResponse.decode(data) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkPolicyTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("WifiBulk policy")
|
||||||
|
struct WifiBulkPolicyTests {
|
||||||
|
private func candidate(
|
||||||
|
payloadBytes: Int = 300_000,
|
||||||
|
capabilities: PeerCapabilities = [.wifiBulk],
|
||||||
|
direct: Bool = true,
|
||||||
|
session: Bool = true
|
||||||
|
) -> WifiBulkPolicy.SendCandidate {
|
||||||
|
WifiBulkPolicy.SendCandidate(
|
||||||
|
payloadBytes: payloadBytes,
|
||||||
|
peerCapabilities: capabilities,
|
||||||
|
isDirectlyConnected: direct,
|
||||||
|
hasEstablishedNoiseSession: session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
|
||||||
|
func eligibleTransferOffered() {
|
||||||
|
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
|
||||||
|
func fallbackMatrix() {
|
||||||
|
// Feature disabled.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
|
||||||
|
// Small file: negotiation overhead not worth it.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
|
||||||
|
// Peer doesn't advertise the capability.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
|
||||||
|
// Multi-hop recipient (reachable but not directly connected).
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
|
||||||
|
// No established Noise session to carry the offer.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
|
||||||
|
// Payload beyond even the Wi-Fi ceiling.
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(
|
||||||
|
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Offer threshold is strictly greater than the minimum")
|
||||||
|
func offerThresholdBoundary() {
|
||||||
|
#expect(!WifiBulkPolicy.shouldOffer(
|
||||||
|
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(WifiBulkPolicy.shouldOffer(
|
||||||
|
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func offer(fileSize: UInt64) -> WifiBulkOffer {
|
||||||
|
WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
|
||||||
|
fileSize: fileSize,
|
||||||
|
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
|
||||||
|
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
|
||||||
|
serviceName: "0011223344556677"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver accepts an in-cap offer from a direct peer")
|
||||||
|
func receiverAccepts() {
|
||||||
|
#expect(WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1_000_000),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver enforces its own size cap, not the sender's word")
|
||||||
|
func receiverSizeCap() {
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 0),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver declines when disabled, indirect, or saturated")
|
||||||
|
func receiverDeclines() {
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1000),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: false
|
||||||
|
))
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1000),
|
||||||
|
senderIsDirectlyConnected: false,
|
||||||
|
activeIncomingTransfers: 0,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
#expect(!WifiBulkPolicy.shouldAccept(
|
||||||
|
offer: offer(fileSize: 1000),
|
||||||
|
senderIsDirectlyConnected: true,
|
||||||
|
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
|
||||||
|
enabled: true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("This build advertises the wifiBulk capability when enabled")
|
||||||
|
func localCapabilityAdvertised() {
|
||||||
|
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
|
||||||
|
func filePacketWifiLimit() throws {
|
||||||
|
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
|
||||||
|
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
|
||||||
|
|
||||||
|
// BLE cap unchanged.
|
||||||
|
#expect(packet.encode() == nil)
|
||||||
|
|
||||||
|
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||||
|
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
|
||||||
|
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||||
|
#expect(decoded.content == content)
|
||||||
|
#expect(decoded.fileName == "big.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// WifiBulkTransferServiceTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// Thread-safe capture box for closures invoked on service queues.
|
||||||
|
final class WifiBulkTestBox<Value>: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var storage: [Value] = []
|
||||||
|
|
||||||
|
func append(_ value: Value) {
|
||||||
|
lock.lock()
|
||||||
|
storage.append(value)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var values: [Value] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int { values.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
func wifiBulkWait(
|
||||||
|
timeout: TimeInterval = 5.0,
|
||||||
|
_ condition: @escaping () -> Bool
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if condition() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
}
|
||||||
|
return condition()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Negotiation/fallback decisions exercised through the real service with the
|
||||||
|
/// network kept on loopback and Bonjour publication disabled.
|
||||||
|
@Suite("WifiBulk transfer service negotiation", .serialized)
|
||||||
|
struct WifiBulkTransferServiceTests {
|
||||||
|
private static let peer = PeerID(str: "aabbccddeeff0011")
|
||||||
|
|
||||||
|
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||||
|
var config = WifiBulkTransferServiceConfig()
|
||||||
|
config.usePeerToPeer = false
|
||||||
|
config.publishBonjourService = false
|
||||||
|
config.offerTimeout = 0.25
|
||||||
|
config.transferWindow = 2.0
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEnvironment(
|
||||||
|
sendNoisePayload: @escaping (Data, PeerID) -> Bool,
|
||||||
|
deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in },
|
||||||
|
progressEvents: WifiBulkTestBox<String>? = nil
|
||||||
|
) -> WifiBulkTransferServiceEnvironment {
|
||||||
|
WifiBulkTransferServiceEnvironment(
|
||||||
|
sendNoisePayload: sendNoisePayload,
|
||||||
|
isPeerConnected: { _ in true },
|
||||||
|
deliverReceivedFile: deliver,
|
||||||
|
progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") },
|
||||||
|
progressChunkSent: { id in progressEvents?.append("chunk:\(id)") },
|
||||||
|
progressReset: { id in progressEvents?.append("reset:\(id)") },
|
||||||
|
progressCancel: { id in progressEvents?.append("cancel:\(id)") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var payload: Data { Data(repeating: 0x42, count: 100_000) }
|
||||||
|
|
||||||
|
@Test("No established Noise session falls straight back to BLE")
|
||||||
|
func noSessionFallsBack() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in false }),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
#expect(service._test_activeOutgoingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unanswered offer times out and falls back exactly once")
|
||||||
|
func offerTimeoutFallsBackOnce() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let progress = WifiBulkTestBox<String>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
// Wait past the transfer window: the expiry must not double-fire.
|
||||||
|
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||||
|
#expect(fallbacks.count == 1)
|
||||||
|
#expect(service._test_activeOutgoingCount == 0)
|
||||||
|
// Progress state was silently reset ahead of the BLE re-start.
|
||||||
|
#expect(progress.values.contains("reset:t-timeout"))
|
||||||
|
#expect(!progress.values.contains("cancel:t-timeout"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Declined offer falls back exactly once, even on duplicate declines")
|
||||||
|
func declineFallsBackOnce() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let offers = WifiBulkTestBox<Data>()
|
||||||
|
var service: WifiBulkTransferService?
|
||||||
|
let environment = makeEnvironment(sendNoisePayload: { typed, peer in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
|
||||||
|
let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true }
|
||||||
|
offers.append(offer.transferID)
|
||||||
|
guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
|
||||||
|
// Deliver the decline twice; the fallback must still fire once.
|
||||||
|
service?.handleResponsePayload(decline, from: peer)
|
||||||
|
service?.handleResponsePayload(decline, from: peer)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
|
||||||
|
service = sut
|
||||||
|
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
#expect(fallbacks.count == 1)
|
||||||
|
#expect(sut._test_activeOutgoingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Responses from the wrong peer are ignored")
|
||||||
|
func wrongPeerResponseIgnored() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
var service: WifiBulkTransferService?
|
||||||
|
let environment = makeEnvironment(sendNoisePayload: { typed, _ in
|
||||||
|
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
|
||||||
|
let offer = WifiBulkOffer.decode(typed.dropFirst()),
|
||||||
|
let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
|
||||||
|
// Decline arrives from an unrelated peer: must be ignored, so the
|
||||||
|
// transfer ends via offer timeout instead.
|
||||||
|
service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788"))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
|
||||||
|
service = sut
|
||||||
|
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
// Not fallen back before the offer timeout window…
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
// …but the timeout still cleans up.
|
||||||
|
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("User cancel tears down without BLE fallback")
|
||||||
|
func userCancelDoesNotFallBack() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let progress = WifiBulkTestBox<String>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
|
||||||
|
service.cancelTransfer(transferId: "t-cancel")
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
#expect(progress.values.contains("cancel:t-cancel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Receiver declines an offer that exceeds its size cap")
|
||||||
|
func receiverDeclinesOversizedOffer() async {
|
||||||
|
let responses = WifiBulkTestBox<Data>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { typed, _ in
|
||||||
|
if typed.first == NoisePayloadType.bulkTransferResponse.rawValue {
|
||||||
|
responses.append(Data(typed.dropFirst()))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
let offer = WifiBulkOffer(
|
||||||
|
transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength),
|
||||||
|
fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
|
||||||
|
payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength),
|
||||||
|
token: Data(repeating: 9, count: WifiBulkWire.tokenLength),
|
||||||
|
serviceName: "0011223344556677"
|
||||||
|
)
|
||||||
|
if let encoded = offer.encode() {
|
||||||
|
service.handleOfferPayload(encoded, from: Self.peer)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { responses.count == 1 })
|
||||||
|
let response = WifiBulkResponse.decode(responses.values[0])
|
||||||
|
#expect(response?.accepted == false)
|
||||||
|
#expect(response?.transferID == offer.transferID)
|
||||||
|
#expect(service._test_activeIncomingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Malformed offers are dropped without a response")
|
||||||
|
func malformedOfferDropped() async {
|
||||||
|
let responses = WifiBulkTestBox<Data>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { typed, _ in
|
||||||
|
responses.append(typed)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer)
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
#expect(responses.count == 0)
|
||||||
|
#expect(service._test_activeIncomingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("stop() tears down active transfers without falling back")
|
||||||
|
func stopTearsDownWithoutFallback() async {
|
||||||
|
let fallbacks = WifiBulkTestBox<Bool>()
|
||||||
|
let service = WifiBulkTransferService(
|
||||||
|
environment: makeEnvironment(sendNoisePayload: { _, _ in true }),
|
||||||
|
config: makeConfig()
|
||||||
|
)
|
||||||
|
service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") {
|
||||||
|
fallbacks.append(true)
|
||||||
|
}
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
|
||||||
|
service.stop()
|
||||||
|
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
|
#expect(fallbacks.count == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,11 +75,6 @@ public final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var didStart = false
|
private var didStart = false
|
||||||
// shutdownCompletely() resets `didStart` asynchronously (after Arti has
|
|
||||||
// actually stopped). A startIfNeeded() arriving in that window must not be
|
|
||||||
// dropped — it is recorded here and honored when the shutdown finishes.
|
|
||||||
private var shutdownsInFlight = 0
|
|
||||||
private var startPendingAfterShutdown = false
|
|
||||||
private var bootstrapMonitorStarted = false
|
private var bootstrapMonitorStarted = false
|
||||||
private var pathMonitor: NWPathMonitor?
|
private var pathMonitor: NWPathMonitor?
|
||||||
private var isAppForeground: Bool = true
|
private var isAppForeground: Bool = true
|
||||||
@@ -95,11 +90,6 @@ public final class TorManager: ObservableObject {
|
|||||||
public func startIfNeeded() {
|
public func startIfNeeded() {
|
||||||
guard allowAutoStart else { return }
|
guard allowAutoStart else { return }
|
||||||
guard isAppForeground else { return }
|
guard isAppForeground else { return }
|
||||||
if shutdownsInFlight > 0 {
|
|
||||||
SecureLogger.debug("TorManager: startIfNeeded() deferred - shutdown in flight", category: .session)
|
|
||||||
startPendingAfterShutdown = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !didStart else { return }
|
guard !didStart else { return }
|
||||||
didStart = true
|
didStart = true
|
||||||
isDormant = false
|
isDormant = false
|
||||||
@@ -339,8 +329,6 @@ public final class TorManager: ObservableObject {
|
|||||||
|
|
||||||
public func shutdownCompletely() {
|
public func shutdownCompletely() {
|
||||||
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
||||||
startPendingAfterShutdown = false
|
|
||||||
shutdownsInFlight += 1
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
_ = arti_stop()
|
_ = arti_stop()
|
||||||
@@ -364,12 +352,6 @@ public final class TorManager: ObservableObject {
|
|||||||
self.bootstrapMonitorStarted = false
|
self.bootstrapMonitorStarted = false
|
||||||
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
||||||
// Clearing it here races with startup and defeats the grace period
|
// Clearing it here races with startup and defeats the grace period
|
||||||
self.shutdownsInFlight -= 1
|
|
||||||
if self.shutdownsInFlight == 0 && self.startPendingAfterShutdown {
|
|
||||||
self.startPendingAfterShutdown = false
|
|
||||||
SecureLogger.debug("TorManager: honoring start deferred during shutdown", category: .session)
|
|
||||||
self.startIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
public enum FileTransferLimits {
|
public enum FileTransferLimits {
|
||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Ceiling for transfers negotiated onto the peer-to-peer Wi-Fi bulk
|
||||||
|
/// channel. The receiver enforces it against the size in the accepted
|
||||||
|
/// offer; the Bluetooth path keeps `maxPayloadBytes`.
|
||||||
|
public static let maxWifiBulkPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
public 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.
|
||||||
@@ -17,7 +21,7 @@ public enum FileTransferLimits {
|
|||||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static func isValidPayload(_ size: Int) -> Bool {
|
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= limit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,19 @@ public enum MessageType: UInt8 {
|
|||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||||
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
||||||
|
|
||||||
// Mesh diagnostics
|
// Mesh diagnostics
|
||||||
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
||||||
case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
|
case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
|
||||||
|
|
||||||
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
|
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
|
||||||
// an internet gateway peer.
|
// an internet gateway peer.
|
||||||
case nostrCarrier = 0x28
|
case nostrCarrier = 0x28
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// LogExportBuffer.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// In-memory ring buffer of the most recent sanitized log lines, so a tester
|
||||||
|
/// can export logs untethered when live streaming isn't available.
|
||||||
|
///
|
||||||
|
/// DEBUG-only: never compiled into release builds, matching the rest of the
|
||||||
|
/// logging stack. Bounded by both a line count and a byte budget (oldest
|
||||||
|
/// evicted first). Appends run on a private serial queue so a hot logging path
|
||||||
|
/// never blocks on buffer maintenance and the main thread is never touched.
|
||||||
|
///
|
||||||
|
/// It stores exactly the SecureLogger-sanitized text (fingerprints truncated,
|
||||||
|
/// base64 redacted, peer IDs shortened), so the export carries no secrets.
|
||||||
|
public final class LogExportBuffer {
|
||||||
|
public static let shared = LogExportBuffer()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.export", qos: .utility)
|
||||||
|
private var lines: [String] = []
|
||||||
|
private var byteCount = 0
|
||||||
|
|
||||||
|
private let maxLines: Int
|
||||||
|
private let maxBytes: Int
|
||||||
|
|
||||||
|
init(maxLines: Int = 2000, maxBytes: Int = 512 * 1024) {
|
||||||
|
self.maxLines = maxLines
|
||||||
|
self.maxBytes = maxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one already-formatted, already-sanitized log line. Non-blocking
|
||||||
|
/// (async on the private queue).
|
||||||
|
func append(_ line: String) {
|
||||||
|
queue.async {
|
||||||
|
self.lines.append(line)
|
||||||
|
self.byteCount += line.utf8.count + 1 // + newline
|
||||||
|
while self.lines.count > self.maxLines || self.byteCount > self.maxBytes {
|
||||||
|
guard !self.lines.isEmpty else { break }
|
||||||
|
let removed = self.lines.removeFirst()
|
||||||
|
self.byteCount -= (removed.utf8.count + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newline-joined snapshot of the buffered lines, oldest first. Safe to
|
||||||
|
/// call from the main thread (brief synchronous read).
|
||||||
|
public func snapshot() -> String {
|
||||||
|
queue.sync { lines.joined(separator: "\n") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clear() {
|
||||||
|
queue.async {
|
||||||
|
self.lines.removeAll(keepingCapacity: true)
|
||||||
|
self.byteCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// LogNetworkSink.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import Foundation
|
||||||
|
#if canImport(Network)
|
||||||
|
import Network
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Best-effort, fire-and-forget UDP forwarder for sanitized log lines, so a
|
||||||
|
/// sideloaded device on iOS 17+ (where `idevicesyslog` Wi-Fi streaming no
|
||||||
|
/// longer works) can stream its logs to a LAN collector with no cable.
|
||||||
|
///
|
||||||
|
/// Design constraints:
|
||||||
|
/// - DEBUG-only: never compiled into release. A privacy-first release build
|
||||||
|
/// carries zero network-log code.
|
||||||
|
/// - Opt-in and off by default: nothing is ever sent until a collector host
|
||||||
|
/// is configured. An empty host means no egress.
|
||||||
|
/// - Never blocks the caller and never throws into the app: every send runs
|
||||||
|
/// on a private queue and failures are silently dropped. A dead or absent
|
||||||
|
/// collector cannot affect app behavior (UDP is connectionless — datagrams
|
||||||
|
/// to nowhere are simply lost).
|
||||||
|
/// - Forwards the exact same sanitized text the ring buffer captures, so no
|
||||||
|
/// secrets leave the device. Each line is prefixed with the device's label
|
||||||
|
/// (`[nickname] …`) so one Mac-side `nc -lu <port>` can demux all devices.
|
||||||
|
public final class LogNetworkSink {
|
||||||
|
public static let shared = LogNetworkSink()
|
||||||
|
|
||||||
|
/// UserDefaults keys shared with the in-app config UI (App Info sheet).
|
||||||
|
public static let hostDefaultsKey = "debug.logSink.host"
|
||||||
|
public static let portDefaultsKey = "debug.logSink.port"
|
||||||
|
public static let defaultPort = 9999
|
||||||
|
/// Where the device label is read from (the app's chosen nickname).
|
||||||
|
public static let nicknameDefaultsKey = "bitchat.nickname"
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.securelogger.netsink", qos: .utility)
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private var label = ""
|
||||||
|
private var enabled = false
|
||||||
|
#if canImport(Network)
|
||||||
|
private var connection: NWConnection?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read collector host/port and the device label from UserDefaults and
|
||||||
|
/// (re)build the UDP connection. Call on launch and whenever the config UI
|
||||||
|
/// changes. An empty host tears the sink down (no egress).
|
||||||
|
public func reloadConfiguration() {
|
||||||
|
let host = (defaults.string(forKey: Self.hostDefaultsKey) ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let storedPort = defaults.integer(forKey: Self.portDefaultsKey)
|
||||||
|
let port = storedPort > 0 ? storedPort : Self.defaultPort
|
||||||
|
let label = (defaults.string(forKey: Self.nicknameDefaultsKey) ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
configure(host: host, port: port, label: label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the sink at `host:port` with a device `label`. Empty host or an
|
||||||
|
/// invalid port disables egress.
|
||||||
|
public func configure(host: String, port: Int, label: String) {
|
||||||
|
queue.async {
|
||||||
|
self.label = label
|
||||||
|
#if canImport(Network)
|
||||||
|
self.connection?.cancel()
|
||||||
|
self.connection = nil
|
||||||
|
guard !host.isEmpty,
|
||||||
|
(1...65535).contains(port),
|
||||||
|
let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
||||||
|
self.enabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .udp)
|
||||||
|
connection.start(queue: self.queue)
|
||||||
|
self.connection = connection
|
||||||
|
self.enabled = true
|
||||||
|
#else
|
||||||
|
self.enabled = false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward one already-formatted, already-sanitized line. Non-blocking;
|
||||||
|
/// drops silently when disabled or on any send failure.
|
||||||
|
func send(_ line: String) {
|
||||||
|
queue.async {
|
||||||
|
guard self.enabled else { return }
|
||||||
|
#if canImport(Network)
|
||||||
|
guard let connection = self.connection else { return }
|
||||||
|
let prefixed = self.label.isEmpty ? line : "[\(self.label)] \(line)"
|
||||||
|
guard let data = (prefixed + "\n").data(using: .utf8) else { return }
|
||||||
|
connection.send(content: data, completion: .idempotent)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -20,4 +20,11 @@ public extension OSLog {
|
|||||||
static let security = OSLog(subsystem: subsystem, category: "security")
|
static let security = OSLog(subsystem: subsystem, category: "security")
|
||||||
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
||||||
static let sync = OSLog(subsystem: subsystem, category: "sync")
|
static let sync = OSLog(subsystem: subsystem, category: "sync")
|
||||||
|
// Added for the observability pass: coarse filtering buckets for the mesh
|
||||||
|
// routing layer, the internet gateway bridge, and the Wi-Fi bulk transport.
|
||||||
|
// The bracket tags in the messages ([ROUTE]/[GW]/[WIFI]/…) remain the
|
||||||
|
// primary filter; these just let `log stream --category` narrow further.
|
||||||
|
static let mesh = OSLog(subsystem: subsystem, category: "mesh")
|
||||||
|
static let gateway = OSLog(subsystem: subsystem, category: "gateway")
|
||||||
|
static let transport = OSLog(subsystem: subsystem, category: "transport")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ public final class SecureLogger {
|
|||||||
case .fault: return 4
|
case .fault: return 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short uppercase label for the in-memory export buffer line prefix.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .debug: return "DEBUG"
|
||||||
|
case .info: return "INFO"
|
||||||
|
case .warning: return "WARN"
|
||||||
|
case .error: return "ERROR"
|
||||||
|
case .fault: return "FAULT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var osLogType: OSLogType {
|
var osLogType: OSLogType {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -100,18 +111,34 @@ public final class SecureLogger {
|
|||||||
|
|
||||||
// MARK: - Global Threshold
|
// MARK: - Global Threshold
|
||||||
|
|
||||||
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
|
/// Minimum level that will be logged. Override via env BITCHAT_LOG_LEVEL
|
||||||
|
/// (env always wins). The default is `.debug` in DEBUG builds and `.info`
|
||||||
|
/// otherwise: a sideloaded, tap-launched build can't receive the env var,
|
||||||
|
/// so DEBUG must surface the `.debug` decision logs out of the box. Release
|
||||||
|
/// emits nothing regardless (all log paths are `#if DEBUG`).
|
||||||
/// Internal-settable so tests can verify level filtering; app code should not mutate it.
|
/// Internal-settable so tests can verify level filtering; app code should not mutate it.
|
||||||
internal static var minimumLevel: LogLevel = {
|
internal static var minimumLevel: LogLevel = defaultMinimumLevel
|
||||||
|
|
||||||
|
/// The level used when nothing mutates `minimumLevel`. Honors the
|
||||||
|
/// BITCHAT_LOG_LEVEL env override; otherwise `.debug` in DEBUG builds (so a
|
||||||
|
/// tap-launched sideload surfaces decision logs) and `.info` elsewhere.
|
||||||
|
/// Exposed for tests.
|
||||||
|
internal static var defaultMinimumLevel: LogLevel {
|
||||||
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
|
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
|
||||||
switch env {
|
switch env {
|
||||||
case "debug": return .debug
|
case "debug": return .debug
|
||||||
|
case "info": return .info
|
||||||
case "warning": return .warning
|
case "warning": return .warning
|
||||||
case "error": return .error
|
case "error": return .error
|
||||||
case "fault": return .fault
|
case "fault": return .fault
|
||||||
default: return .info
|
default:
|
||||||
|
#if DEBUG
|
||||||
|
return .debug
|
||||||
|
#else
|
||||||
|
return .info
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}()
|
}
|
||||||
|
|
||||||
private static func shouldLog(_ level: LogLevel) -> Bool {
|
private static func shouldLog(_ level: LogLevel) -> Bool {
|
||||||
return level.order >= minimumLevel.order
|
return level.order >= minimumLevel.order
|
||||||
@@ -168,6 +195,7 @@ public extension SecureLogger {
|
|||||||
|
|
||||||
#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)
|
||||||
|
record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)")
|
||||||
#else
|
#else
|
||||||
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
#endif
|
#endif
|
||||||
@@ -258,9 +286,10 @@ private extension SecureLogger {
|
|||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = "\(location) \(message())".sanitized()
|
let sanitized = "\(location) \(message())".sanitized()
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
|
record(level: level, message: sanitized)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a security event
|
/// Log a security event
|
||||||
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
|
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
|
||||||
file: String, line: Int, function: String) {
|
file: String, line: Int, function: String) {
|
||||||
@@ -269,6 +298,18 @@ private extension SecureLogger {
|
|||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let message = "\(location) \(event.message)"
|
let message = "\(location) \(event.message)"
|
||||||
os_log("%{public}@", log: .security, type: level.osLogType, message)
|
os_log("%{public}@", log: .security, type: level.osLogType, message)
|
||||||
|
record(level: level, message: message)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fan a sanitized line out to the in-memory export buffer and the
|
||||||
|
/// (opt-in, off by default) UDP network sink. DEBUG-only; both consumers
|
||||||
|
/// are non-blocking. Same formatted text everywhere.
|
||||||
|
static func record(level: LogLevel, message: String) {
|
||||||
|
#if DEBUG
|
||||||
|
let line = "[\(level.label)] \(message)"
|
||||||
|
LogExportBuffer.shared.append(line)
|
||||||
|
LogNetworkSink.shared.send(line)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,25 @@ final class LogLevelFilteringTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(counter.count, 1, "Enabled levels must still log")
|
XCTAssertEqual(counter.count, 1, "Enabled levels must still log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sideloaded, tap-launched build can't receive BITCHAT_LOG_LEVEL, so the
|
||||||
|
/// DEBUG default must be `.debug` (not `.info`) or the `.debug` decision
|
||||||
|
/// logs stay invisible on device. Release still defaults to `.info` and
|
||||||
|
/// emits nothing regardless (all log paths are `#if DEBUG`).
|
||||||
|
func testDebugBuildDefaultsToDebugLevelUnlessOverridden() throws {
|
||||||
|
// Only meaningful when the env override isn't set (CI sometimes sets it).
|
||||||
|
try XCTSkipUnless(
|
||||||
|
ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"] == nil,
|
||||||
|
"BITCHAT_LOG_LEVEL is set; default-level assertion doesn't apply"
|
||||||
|
)
|
||||||
|
#if DEBUG
|
||||||
|
XCTAssertEqual(
|
||||||
|
SecureLogger.defaultMinimumLevel,
|
||||||
|
.debug,
|
||||||
|
"DEBUG builds must default to .debug so tap-launched sideloads surface decision logs"
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
XCTAssertEqual(SecureLogger.defaultMinimumLevel, .info)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user