mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:45:20 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f677776bb | ||
|
|
5dffb04912 | ||
|
|
81c45e9649 | ||
|
|
963d3a089f | ||
|
|
f79c2e3e9f | ||
|
|
0c3136282e | ||
|
|
b043324035 | ||
|
|
bbe5e1ef4e | ||
|
|
28ffc53f3f | ||
|
|
8415c52913 | ||
|
|
a0c517c018 | ||
|
|
81a10f73f0 | ||
|
|
87910541ef | ||
|
|
f9032cf2b9 | ||
|
|
d4f0c49787 | ||
|
|
2360140760 | ||
|
|
70229f0be1 | ||
|
|
cee2bcd535 | ||
|
|
3c610a83cd | ||
|
|
60be88a4f5 | ||
|
|
ede6368296 | ||
|
|
276cde44e7 | ||
|
|
201dbac49a | ||
|
|
5fdc15d5af |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.4
|
||||
MARKETING_VERSION = 1.6.0
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -28,6 +28,7 @@ final class AppRuntime: ObservableObject {
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -91,6 +92,24 @@ final class AppRuntime: ObservableObject {
|
||||
chatViewModel: self.chatViewModel,
|
||||
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()
|
||||
bindRuntimeObservers()
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
#if DEBUG
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
@@ -43,14 +40,10 @@ struct BitchatApp: App {
|
||||
.environmentObject(runtime.locationChannelsModel)
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
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
|
||||
runtime.handleOpenURL(url)
|
||||
|
||||
@@ -33,18 +33,12 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_bitchat-bulk._tcp</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<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>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
+25782
-491
File diff suppressed because it is too large
Load Diff
@@ -74,11 +74,11 @@ enum CommandInfo: String, Identifiable {
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
// The processor rejects favorites, groups and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ struct NostrProtocol {
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -256,16 +257,22 @@ struct NostrProtocol {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil
|
||||
nickname: String? = nil,
|
||||
expiresAt: Date? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if let expiresAt {
|
||||
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
@@ -276,7 +283,25 @@ struct NostrProtocol {
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
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
|
||||
|
||||
private static func createSeal(
|
||||
|
||||
@@ -28,14 +28,12 @@ struct BitchatFilePacket {
|
||||
|
||||
/// 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).
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||
func encode() -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
@@ -68,9 +66,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
/// `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? {
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
@@ -130,7 +126,7 @@ struct BitchatFilePacket {
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(limit) {
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
@@ -139,7 +135,7 @@ struct BitchatFilePacket {
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > limit {
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
@@ -149,7 +145,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
|
||||
@@ -74,10 +74,7 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// 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
|
||||
// Private groups (0x04/0x05 reserved by other features)
|
||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Verification (QR-based OOB binding)
|
||||
@@ -91,8 +88,6 @@ enum NoisePayloadType: UInt8 {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
|
||||
@@ -18,6 +18,14 @@ enum Geohash {
|
||||
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.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
|
||||
@@ -3,9 +3,5 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = {
|
||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||
return caps
|
||||
}()
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||
}
|
||||
|
||||
@@ -44,9 +44,7 @@ final class BLEFileTransferHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// `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) {
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||
|
||||
@@ -71,7 +69,7 @@ final class BLEFileTransferHandler {
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
|
||||
@@ -42,17 +42,12 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||
/// 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 {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,13 +33,21 @@ final class BLEFragmentHandler {
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
// Don't process our own fragments
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
// 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 header.isBroadcastFragment {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
if header.isBroadcastFragment {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,16 +51,16 @@ struct BLEReceivePipeline {
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
// Ping/pong diagnostics also ride it: probes need the same
|
||||
// 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
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil,
|
||||
|| packet.type == MessageType.pong.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,6 +90,8 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||
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
|
||||
// inbound ping budget — keyed by the ingress link (the directly connected
|
||||
@@ -109,10 +111,6 @@ final class BLEService: NSObject {
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
|
||||
// Route health for originated source routes; guarded by collectionsQueue.
|
||||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||||
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||
@@ -190,8 +188,6 @@ final class BLEService: NSObject {
|
||||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||||
// File-transfer orchestration (queue hops stay in the environment closures)
|
||||
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
|
||||
private var gossipSyncManager: GossipSyncManager?
|
||||
@@ -201,6 +197,7 @@ final class BLEService: NSObject {
|
||||
|
||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||
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
|
||||
/// tests), periodic mesh background work is not started — the maintenance
|
||||
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
||||
@@ -211,6 +208,9 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: - Connection budget & scheduling (central role)
|
||||
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
|
||||
private var scanDutyTimer: DispatchSourceTimer?
|
||||
@@ -316,12 +316,6 @@ final class BLEService: NSObject {
|
||||
|
||||
// Initialize gossip sync manager
|
||||
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() {
|
||||
@@ -570,9 +564,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
// Tear down any Wi-Fi bulk listeners/connections deterministically
|
||||
wifiBulkService.stop()
|
||||
|
||||
// Send leave message synchronously to ensure delivery
|
||||
var leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
@@ -792,9 +783,6 @@ final class BLEService: NSObject {
|
||||
// MARK: Messaging
|
||||
|
||||
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
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -870,72 +858,10 @@ final class BLEService: NSObject {
|
||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// 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 {
|
||||
guard let payload = filePacket.encode() else {
|
||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||
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
|
||||
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
||||
let targetID = peerID.toShort()
|
||||
@@ -964,13 +890,6 @@ 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) {
|
||||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||||
@@ -1356,57 +1275,6 @@ 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) {
|
||||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||||
|
||||
@@ -1651,6 +1519,14 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
assembler: assembler
|
||||
)
|
||||
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")
|
||||
@@ -1666,6 +1542,17 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
|
||||
switch central.state {
|
||||
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
|
||||
startScanning()
|
||||
|
||||
@@ -1745,6 +1632,9 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
isConnectable: isConnectable,
|
||||
discoveredAt: Date()
|
||||
)
|
||||
if isConnectable {
|
||||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
|
||||
}
|
||||
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
|
||||
|
||||
switch connectionScheduler.handleDiscovery(
|
||||
@@ -1771,7 +1661,15 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
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
|
||||
linkStateStore.markConnected(peripheral)
|
||||
|
||||
@@ -1796,7 +1694,26 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
if error != nil {
|
||||
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
|
||||
_ = linkStateStore.removePeripheral(peripheralID)
|
||||
if let peerID {
|
||||
@@ -1924,6 +1841,18 @@ extension BLEService {
|
||||
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)
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||
@@ -2003,34 +1932,6 @@ extension BLEService {
|
||||
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
|
||||
|
||||
@@ -2703,7 +2604,7 @@ extension BLEService {
|
||||
|
||||
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
||||
let now = Date()
|
||||
let decision = BLESourceRouteOriginationPolicy.decide(
|
||||
let route = BLESourceRouteOriginationPolicy.route(
|
||||
for: packet,
|
||||
to: recipient,
|
||||
localPeerIDData: myPeerIDData,
|
||||
@@ -2715,19 +2616,7 @@ extension BLEService {
|
||||
},
|
||||
computeRoute: { self.computeRoute(to: $0) }
|
||||
)
|
||||
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
|
||||
}
|
||||
guard let route else { return packet }
|
||||
// Create new packet with route applied and version upgraded to 2
|
||||
let routedPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
@@ -2817,7 +2706,7 @@ extension BLEService {
|
||||
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
||||
SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))…", category: .mesh)
|
||||
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -2825,7 +2714,7 @@ extension BLEService {
|
||||
}
|
||||
guard allowed else {
|
||||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||||
SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .mesh)
|
||||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2859,7 +2748,6 @@ extension BLEService {
|
||||
rttMs: max(0, rttMs),
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -2868,15 +2756,11 @@ extension BLEService {
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
refreshLocalTopology()
|
||||
if let route = computeRoute(to: peerID) {
|
||||
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
|
||||
return route.compactMap { PeerID(routingData: $0) }
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
return isPeerConnected(peerID) ? [] : nil
|
||||
}
|
||||
|
||||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||||
@@ -3022,11 +2906,9 @@ extension BLEService {
|
||||
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||||
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||||
prekeyID = prekey.id
|
||||
SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))…", category: .session)
|
||||
} else {
|
||||
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
prekeyID = nil
|
||||
SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))…", category: .session)
|
||||
}
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
@@ -3120,7 +3002,6 @@ extension BLEService {
|
||||
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
|
||||
if opened.consumedPrekey {
|
||||
let replenished = noiseService.replenishPrekeysIfNeeded()
|
||||
SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session)
|
||||
sendPrekeyBundle(force: replenished)
|
||||
}
|
||||
} else {
|
||||
@@ -3352,7 +3233,7 @@ extension BLEService {
|
||||
return nil
|
||||
}
|
||||
guard let signingKey else {
|
||||
SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
|
||||
return
|
||||
}
|
||||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||||
@@ -3363,13 +3244,11 @@ extension BLEService {
|
||||
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
|
||||
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
|
||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
|
||||
return
|
||||
}
|
||||
if prekeyBundleStore.ingest(bundle) {
|
||||
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)
|
||||
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
|
||||
}
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
@@ -3593,11 +3472,12 @@ extension BLEService {
|
||||
centralManager?.stopScan()
|
||||
startScanning()
|
||||
}
|
||||
cancelStalePendingConnects()
|
||||
logBluetoothStatus("became-active")
|
||||
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
|
||||
// No Local Name; nothing to refresh for advertising policy
|
||||
}
|
||||
|
||||
|
||||
@objc private func appDidEnterBackground() {
|
||||
isAppActive = false
|
||||
// Restart scanning without allow duplicates in background
|
||||
@@ -3605,6 +3485,7 @@ extension BLEService {
|
||||
centralManager?.stopScan()
|
||||
startScanning()
|
||||
}
|
||||
armPendingBackgroundConnects()
|
||||
// Backgrounding may precede a kill; flush the public-history archive
|
||||
// outside its 30s maintenance cadence.
|
||||
gossipSyncManager?.persistNow()
|
||||
@@ -3612,6 +3493,81 @@ extension BLEService {
|
||||
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||||
// 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
|
||||
|
||||
// MARK: Private Message Handling
|
||||
@@ -3960,6 +3916,16 @@ 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
|
||||
switch context.messageType {
|
||||
case .announce:
|
||||
@@ -3986,12 +3952,15 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
case .groupMessage:
|
||||
handleGroupMessage(packet, from: senderID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
case .boardPost:
|
||||
// Invalid or deleted posts must not spread; skip the relay step.
|
||||
guard handleBoardPost(packet, from: senderID) else { return }
|
||||
case .nostrCarrier:
|
||||
handleNostrCarrier(packet, from: peerID)
|
||||
|
||||
@@ -4004,10 +3973,6 @@ extension BLEService {
|
||||
case .pong:
|
||||
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:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -4391,21 +4356,8 @@ extension BLEService {
|
||||
self?.noiseService.clearSession(for: peerID)
|
||||
},
|
||||
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`.
|
||||
self.notifyUI { [weak self] in
|
||||
self?.notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
peerID: peerID,
|
||||
type: type,
|
||||
@@ -4470,6 +4422,7 @@ extension BLEService {
|
||||
|
||||
private func performMaintenance() {
|
||||
maintenanceCounter += 1
|
||||
lastMaintenanceAt = Date()
|
||||
|
||||
let now = Date()
|
||||
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
||||
@@ -4534,6 +4487,18 @@ 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() {
|
||||
let now = Date()
|
||||
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
|
||||
|
||||
@@ -4,27 +4,8 @@ import Foundation
|
||||
/// 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.
|
||||
enum BLESourceRouteOriginationPolicy {
|
||||
/// Why a packet kept flood/direct-write behavior instead of routing.
|
||||
/// 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.
|
||||
/// Returns the intermediate-hop route to attach, or nil to keep the
|
||||
/// current flood/direct-write behavior unchanged.
|
||||
///
|
||||
/// Routes are only originated when every gate passes:
|
||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||
@@ -38,22 +19,22 @@ enum BLESourceRouteOriginationPolicy {
|
||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||
/// routed send, and
|
||||
/// - the topology yields a complete path.
|
||||
static func decide(
|
||||
static func route(
|
||||
for packet: BitchatPacket,
|
||||
to recipient: PeerID,
|
||||
localPeerIDData: Data,
|
||||
isRecipientConnected: (PeerID) -> Bool,
|
||||
shouldAttemptRoute: (PeerID) -> Bool,
|
||||
computeRoute: (PeerID) -> [Data]?
|
||||
) -> Decision {
|
||||
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
|
||||
) -> [Data]? {
|
||||
guard packet.senderID == localPeerIDData else { return nil }
|
||||
guard let recipientData = packet.recipientID,
|
||||
recipientData.count == 8,
|
||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
|
||||
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
|
||||
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
|
||||
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
|
||||
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
|
||||
return .route(route)
|
||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
||||
guard packet.ttl > 1 else { return nil }
|
||||
guard !isRecipientConnected(recipient) else { return nil }
|
||||
guard shouldAttemptRoute(recipient) else { return nil }
|
||||
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
||||
return route
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// 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,17 +20,28 @@ final class BoardManager: ObservableObject {
|
||||
|
||||
private let transport: Transport
|
||||
private let store: BoardStore
|
||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
|
||||
/// Publishes a bridged kind-1 note (expiring with the board post via
|
||||
/// 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?
|
||||
|
||||
init(
|
||||
transport: Transport,
|
||||
store: BoardStore = .shared,
|
||||
publishToNostr: ((String, String, String) -> Void)? = nil
|
||||
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
|
||||
deleteFromNostr: ((String, String) -> Void)? = nil
|
||||
) {
|
||||
self.transport = transport
|
||||
self.store = store
|
||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
||||
cancellable = store.$postsSnapshot
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] snapshot in
|
||||
@@ -115,10 +126,10 @@ final class BoardManager: ObservableObject {
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||
|
||||
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
|
||||
// location notes so online users see them. No inbound merge yet.
|
||||
if !geohash.isEmpty {
|
||||
publishToNostr(trimmed, geohash, cleanNickname)
|
||||
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
||||
// online users see them. Remember the event id for merged deletes.
|
||||
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
|
||||
bridgedEventIDs[postID] = eventID
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -140,14 +151,20 @@ final class BoardManager: ObservableObject {
|
||||
signature: signature
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
|
||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||
@@ -155,11 +172,26 @@ final class BoardManager: ObservableObject {
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname
|
||||
nickname: nickname,
|
||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
|
||||
)
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
return event.id
|
||||
} catch {
|
||||
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,6 +78,16 @@ final class BoardStore {
|
||||
/// Live posts, published on the main thread for the board UI.
|
||||
@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 tombstones: [StoredTombstone] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
||||
@@ -104,6 +114,11 @@ final class BoardStore {
|
||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
if result == .accepted {
|
||||
persistLocked()
|
||||
if case .post(let post) = wire {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postArrivals.send(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -149,6 +164,9 @@ final class BoardStore {
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.didWipe.send()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// 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,7 +38,11 @@ import Foundation
|
||||
/// original broadcast packet is the TTL relay's job, not ours.
|
||||
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
||||
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
||||
/// repeat deposits and relay echoes are absorbed.
|
||||
/// repeat deposits and relay echoes are absorbed. An event this gateway
|
||||
/// 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
|
||||
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
||||
/// carrier never re-enter the uplink path. This is a call-site convention;
|
||||
@@ -152,7 +156,7 @@ final class GatewayService: ObservableObject {
|
||||
pendingDownlinks.removeAll()
|
||||
uplinkDepositTimes.removeAll()
|
||||
}
|
||||
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
|
||||
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||
onEnabledChanged?(enabled)
|
||||
}
|
||||
|
||||
@@ -163,7 +167,7 @@ final class GatewayService: ObservableObject {
|
||||
/// for broadcasts (downlink rebroadcasts from a gateway).
|
||||
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
||||
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
||||
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway)
|
||||
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
switch carrier.direction {
|
||||
@@ -186,7 +190,7 @@ final class GatewayService: ObservableObject {
|
||||
// age) — no crypto — so junk and stale replays are dropped before we
|
||||
// ever pay for a MainActor Schnorr verify.
|
||||
guard let event = structurallyValidEvent(from: carrier) else {
|
||||
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||
return
|
||||
}
|
||||
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||
@@ -197,19 +201,18 @@ final class GatewayService: ObservableObject {
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Consume the per-depositor rate token BEFORE the expensive verify so
|
||||
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
||||
// not by main-actor Schnorr verifications.
|
||||
guard allowUplinkDeposit(from: depositor) else {
|
||||
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
// Only now pay for cryptographic verification; receivers verify again.
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -219,9 +222,6 @@ final class GatewayService: ObservableObject {
|
||||
accepted = true
|
||||
} else {
|
||||
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
|
||||
@@ -247,7 +247,7 @@ final class GatewayService: ObservableObject {
|
||||
private func publish(_ event: NostrEvent, geohash: String) {
|
||||
publishedEventIDs.insert(event.id)
|
||||
publishToRelays?(event, geohash)
|
||||
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
|
||||
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
||||
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway)
|
||||
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session)
|
||||
return false
|
||||
}
|
||||
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||
@@ -298,26 +298,26 @@ final class GatewayService: ObservableObject {
|
||||
// event's own `#g` tag to match the carrier geohash.
|
||||
guard isFresh(event),
|
||||
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
|
||||
}
|
||||
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||
// mesh. Loop rule 2: rebroadcast each 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
|
||||
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
|
||||
// gateway itself uplinked (`publishedEventIDs`) — it originated on this
|
||||
// very mesh, so our own relay subscription echoing it back must not
|
||||
// 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
|
||||
// still waiting to be sent.
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!rebroadcastEventIDs.contains(event.id),
|
||||
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
||||
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Verify before spending BLE airtime; receivers verify again.
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
|
||||
pendingDownlinks.append((event, geohash))
|
||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||
@@ -325,7 +325,6 @@ final class GatewayService: ObservableObject {
|
||||
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
||||
// relay redelivery can still carry it.
|
||||
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
|
||||
}
|
||||
drainPendingDownlinks()
|
||||
}
|
||||
@@ -342,7 +341,6 @@ final class GatewayService: ObservableObject {
|
||||
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||
let payload = carrier.encode() else { continue }
|
||||
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
|
||||
// rebroadcast (loop rule 2).
|
||||
rebroadcastEventIDs.insert(event.id)
|
||||
@@ -362,11 +360,9 @@ final class GatewayService: ObservableObject {
|
||||
let oldest = downlinkSendTimes.min() ?? now()
|
||||
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||
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
|
||||
guard let self else { return }
|
||||
self.downlinkDrainScheduled = false
|
||||
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
|
||||
self.drainPendingDownlinks()
|
||||
}
|
||||
if let scheduleDrainTimer {
|
||||
@@ -421,7 +417,7 @@ final class GatewayService: ObservableObject {
|
||||
return false
|
||||
}
|
||||
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
||||
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway)
|
||||
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,50 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = 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
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
@@ -62,7 +105,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||
kSecAttrAccessible as String: Self.itemAccessibility,
|
||||
kSecAttrLabel as String: "bitchat-\(key)"
|
||||
]
|
||||
#if os(macOS)
|
||||
@@ -227,7 +270,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||
kSecAttrAccessible as String: Self.itemAccessibility,
|
||||
kSecAttrLabel as String: "bitchat-\(key)"
|
||||
]
|
||||
#if os(macOS)
|
||||
|
||||
@@ -67,6 +67,9 @@ final class LocationNotesManager: ObservableObject {
|
||||
let content: String
|
||||
let createdAt: Date
|
||||
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 {
|
||||
let suffix = String(pubkey.suffix(4))
|
||||
@@ -82,6 +85,8 @@ final class LocationNotesManager: ObservableObject {
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var state: State = .loading
|
||||
@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 noteIDs = Set<String>() // O(1) duplicate detection
|
||||
private var directoryUpdateCancellable: AnyCancellable?
|
||||
@@ -104,10 +109,10 @@ final class LocationNotesManager: ObservableObject {
|
||||
let norm = geohash.lowercased()
|
||||
self.geohash = norm
|
||||
self.dependencies = dependencies
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
if !Geohash.isValidBuildingGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
if !Geohash.isValidGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
||||
}
|
||||
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
||||
subscribe()
|
||||
// The relay directory may load after init (remote fetch over Tor);
|
||||
// retry automatically instead of staying stuck on "no relays".
|
||||
@@ -123,9 +128,8 @@ final class LocationNotesManager: ObservableObject {
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
guard Geohash.isValidGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
@@ -137,6 +141,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
@@ -193,14 +198,14 @@ final class LocationNotesManager: ObservableObject {
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag - accept any of our 9 geohashes
|
||||
guard event.tags.contains(where: { tag in
|
||||
guard let matchedGeohash = event.tags.first(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||
}) else { return }
|
||||
})?[1].lowercased() else { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash)
|
||||
self.notes.append(note)
|
||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||
self.enforceMemoryCap()
|
||||
@@ -239,7 +244,8 @@ final class LocationNotesManager: ObservableObject {
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
nickname: nickname
|
||||
nickname: nickname,
|
||||
geohash: geohash
|
||||
)
|
||||
self.noteIDs.insert(event.id)
|
||||
self.notes.insert(echo, at: 0)
|
||||
@@ -252,6 +258,36 @@ 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).
|
||||
private func enforceMemoryCap() {
|
||||
if notes.count > maxNotesInMemory {
|
||||
|
||||
@@ -172,17 +172,37 @@ final class MessageDeduplicationService {
|
||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||
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.
|
||||
/// - Parameters:
|
||||
/// - contentCapacity: Max entries for content cache
|
||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
||||
/// - nostrEventStore: Optional disk store preloading and persisting
|
||||
/// processed Nostr event IDs across launches
|
||||
init(
|
||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap,
|
||||
nostrEventStore: NostrProcessedEventStore? = nil
|
||||
) {
|
||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
||||
self.nostrEventCache = 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
|
||||
@@ -239,6 +259,26 @@ final class MessageDeduplicationService {
|
||||
/// - Parameter eventId: The event ID
|
||||
func recordNostrEvent(_ eventId: String) {
|
||||
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
|
||||
@@ -263,14 +303,20 @@ final class MessageDeduplicationService {
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
/// Clears all caches
|
||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||
/// gift-wrap record goes with everything else.
|
||||
func clearAll() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
pendingPersistIDs.removeAll()
|
||||
nostrEventStore?.wipe()
|
||||
}
|
||||
|
||||
/// Clears only the Nostr caches (events and ACKs)
|
||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||
/// 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() {
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
|
||||
@@ -25,14 +25,20 @@ extension NostrRelayManager: NetworkActivationRelayControlling {}
|
||||
extension TorURLSession: NetworkActivationProxyControlling {}
|
||||
|
||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||
/// Policy: permit start when (location permissions are authorized OR there
|
||||
/// exists at least one mutual favorite) AND the device has a usable network
|
||||
/// 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
|
||||
final class NetworkActivationService: ObservableObject {
|
||||
static let shared = NetworkActivationService()
|
||||
|
||||
@Published private(set) var activationAllowed: Bool = false
|
||||
@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 started = false
|
||||
@@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let reachabilityMonitor: NetworkReachabilityMonitoring
|
||||
private let torController: NetworkActivationTorControlling
|
||||
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
||||
// (via its live dependencies), so capturing NostrRelayManager.shared here would
|
||||
@@ -58,6 +65,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
reachabilityMonitor = NWPathReachabilityMonitor()
|
||||
torController = TorManager.shared
|
||||
relayControllerProvider = { NostrRelayManager.shared }
|
||||
proxyController = TorURLSession.shared
|
||||
@@ -70,6 +78,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||
reachabilityMonitor: NetworkReachabilityMonitoring,
|
||||
torController: NetworkActivationTorControlling,
|
||||
relayController: NetworkActivationRelayControlling,
|
||||
proxyController: NetworkActivationProxyControlling,
|
||||
@@ -80,6 +89,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.reachabilityMonitor = reachabilityMonitor
|
||||
self.torController = torController
|
||||
self.relayControllerProvider = { relayController }
|
||||
self.proxyController = proxyController
|
||||
@@ -96,8 +106,12 @@ final class NetworkActivationService: ObservableObject {
|
||||
userTorEnabled = true
|
||||
}
|
||||
|
||||
// Begin (idempotent) reachability monitoring and seed initial state.
|
||||
reachabilityMonitor.start()
|
||||
isNetworkReachable = reachabilityMonitor.isReachable
|
||||
|
||||
// Initial compute
|
||||
let allowed = basePolicyAllowed()
|
||||
let allowed = effectiveAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||
@@ -123,6 +137,21 @@ final class NetworkActivationService: ObservableObject {
|
||||
self?.reevaluate()
|
||||
}
|
||||
.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) {
|
||||
@@ -138,7 +167,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
|
||||
private func reevaluate() {
|
||||
let allowed = basePolicyAllowed()
|
||||
let allowed = effectiveAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
let torChanged = torDesired != torAutoStartDesired
|
||||
@@ -163,12 +192,20 @@ 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 {
|
||||
let permOK = permissionProvider() == .authorized
|
||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||
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) {
|
||||
proxyController.setProxyMode(useTor: torDesired)
|
||||
if torDesired {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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,7 +463,6 @@ final class NoiseEncryptionService {
|
||||
/// 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) {
|
||||
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
|
||||
SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session)
|
||||
throw NoiseEncryptionError.unknownPrekey
|
||||
}
|
||||
let handshake = NoiseHandshakeState(
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// 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,6 +19,36 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// still queue.
|
||||
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
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
@@ -33,7 +63,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
||||
ackPacer: NostrTransport.sharedAckPacer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,14 +72,64 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits
|
||||
private struct QueuedRead {
|
||||
let receipt: ReadReceipt
|
||||
let peerID: PeerID
|
||||
// Throttle outbound acks — READ receipts and DELIVERED acks, direct and
|
||||
// geohash — to avoid relay rate limits. Reconnect redelivery produces a
|
||||
// burst of acks at once: 8 DELIVERED in under a second tripped damus's
|
||||
// "noting too much" during July 2026 device testing.
|
||||
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] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
|
||||
/// Ack pacing shared across transport instances. Geohash acks are sent
|
||||
/// through short-lived transports created per ack
|
||||
/// (`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 idBridge: NostrIdentityBridge
|
||||
private let dependencies: Dependencies
|
||||
@@ -187,12 +268,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
// Use barrier to synchronize access to readQueue
|
||||
queue.async(flags: .barrier) {
|
||||
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
self.processReadQueueIfNeeded()
|
||||
}
|
||||
enqueueAck(.readDirect(receipt, peerID))
|
||||
}
|
||||
|
||||
/// Enqueue an ack for paced sending. Captures self strongly on purpose:
|
||||
/// geohash acks ride throwaway transport instances that must stay alive
|
||||
/// until their ack leaves the queue.
|
||||
private func enqueueAck(_ ack: QueuedAck) {
|
||||
dependencies.ackPacer.enqueue { self.sendAckItem(ack) }
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
@@ -212,17 +295,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,19 +305,11 @@ extension NostrTransport {
|
||||
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
||||
}
|
||||
|
||||
// MARK: Geohash DMs (per-geohash identity)
|
||||
@@ -290,36 +355,42 @@ extension NostrTransport {
|
||||
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 read ack item (called after extraction from queue within barrier)
|
||||
private func sendReadAckItem(_ item: QueuedRead) {
|
||||
/// Sends a single ack item (invoked by the pacer, one per interval)
|
||||
private func sendAckItem(_ item: QueuedAck) {
|
||||
Task { @MainActor in
|
||||
defer { scheduleNextReadAck() }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
switch item {
|
||||
case .readDirect(let receipt, let peerID):
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
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)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
|
||||
private func scheduleNextReadAck() {
|
||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||
self?.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.isSendingReadAcks = false
|
||||
self?.processReadQueueIfNeeded()
|
||||
case .deliveredDirect(let messageID, let peerID):
|
||||
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)
|
||||
|
||||
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,6 +167,10 @@ protocol Transport: AnyObject {
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
||||
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
|
||||
// queue-backed transports (e.g. NostrTransport) stay untouched.
|
||||
/// Sends a directed ping probe; the completion fires exactly once on the
|
||||
@@ -178,10 +182,6 @@ protocol Transport: AnyObject {
|
||||
/// Current mesh graph for the topology map; nil when unsupported.
|
||||
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)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -223,13 +223,14 @@ extension Transport {
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
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 peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
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
|
||||
// "no reply"/"no path" rather than pretending to measure anything.
|
||||
@@ -238,7 +239,6 @@ extension Transport {
|
||||
}
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||
func sendBoardPayload(_ payload: Data) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -242,6 +242,16 @@ enum TransportConfig {
|
||||
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
||||
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
|
||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||
static let bleWeakLinkRSSICutoff: Int = -90
|
||||
@@ -305,21 +315,6 @@ enum TransportConfig {
|
||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||
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
|
||||
// Initial spray-and-wait budget per deposited envelope: each courier may
|
||||
// hand half its remaining copies to another courier on encounter, so a
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let maxAgeSeconds: TimeInterval
|
||||
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.
|
||||
let maxAgeSeconds: TimeInterval
|
||||
switch packet.type {
|
||||
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||
case MessageType.prekeyBundle.rawValue:
|
||||
@@ -275,6 +275,13 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
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:
|
||||
// Callers only feed verified bundles here (own bundles at send
|
||||
// time, peers' after signature verification), so gossip never
|
||||
@@ -298,13 +305,6 @@ final class GossipSyncManager {
|
||||
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
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:
|
||||
break
|
||||
}
|
||||
@@ -313,6 +313,7 @@ final class GossipSyncManager {
|
||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||
// Unicast sync to connected peers to allow RSR attribution
|
||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||
SecureLogger.debug("Sending periodic sync (\(types.logDescription)) to \(connectedPeers.count) connected peers", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
@@ -349,7 +350,7 @@ final class GossipSyncManager {
|
||||
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
|
||||
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
|
||||
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
|
||||
SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh)
|
||||
SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
|
||||
}
|
||||
@@ -390,7 +391,7 @@ final class GossipSyncManager {
|
||||
// A response can replay the whole store, so bound how often one peer
|
||||
// can trigger a diff pass regardless of how fast it asks.
|
||||
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
||||
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
return
|
||||
}
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
@@ -398,9 +399,6 @@ final class GossipSyncManager {
|
||||
// older packets are outside the filter but not missing, and without
|
||||
// the cursor they would be re-sent every round.
|
||||
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
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -420,7 +418,6 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,7 +432,6 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,7 +456,6 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,26 +470,6 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||
// 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
|
||||
// bundles generated long before it arrived.
|
||||
if requestedTypes.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,11 +484,26 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||
// 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
|
||||
// bundles generated long before it arrived.
|
||||
if requestedTypes.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
if requestedTypes.contains(.boardPost) {
|
||||
// The board store already filters to live posts and tombstones;
|
||||
// no freshness window applies (posts sync until their own expiry).
|
||||
@@ -526,14 +516,9 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
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
|
||||
@@ -553,14 +538,14 @@ final class GossipSyncManager {
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.groupMessage) {
|
||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
if types.contains(.groupMessage) {
|
||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.boardPost) {
|
||||
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||
}
|
||||
@@ -625,10 +610,10 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||
}
|
||||
|
||||
// MARK: - Archive (public message persistence)
|
||||
@@ -677,7 +662,6 @@ final class GossipSyncManager {
|
||||
// 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
|
||||
// fragment traffic can't crowd messages out of the filter.
|
||||
var dueLabels: [String] = []
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
// No board source wired up means nothing to offer or store;
|
||||
@@ -686,14 +670,8 @@ final class GossipSyncManager {
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
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) {
|
||||
|
||||
@@ -36,29 +36,29 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
case .boardPost: return 8
|
||||
// Extended bits are compat-safe by construction: `toData()` encodes
|
||||
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
||||
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
||||
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
||||
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
||||
// ignore the group bit and answer with the types they know.
|
||||
case .boardPost: return 8
|
||||
case .groupMessage: return 10
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
// The bitfield is a wire-tolerant little-endian UInt64 (1-8 bytes,
|
||||
// unknown high bits ignored by `type(forBit:)`), so bits 8+ need no
|
||||
// format change: old clients decode the wider flags and simply never
|
||||
// match the new bits.
|
||||
case .prekeyBundle: return 9
|
||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||
// sync would only produce stale, unanswerable echoes.
|
||||
case .ping, .pong: return nil
|
||||
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||
// would waste airtime and extend their lifetime.
|
||||
case .nostrCarrier: return nil
|
||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||
// sync would only produce stale, unanswerable echoes.
|
||||
case .ping, .pong: return nil
|
||||
// Prekey bundles gossip like board posts. The bitfield is a
|
||||
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
|
||||
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
|
||||
// clients decode the wider flags and simply never match the new bits.
|
||||
case .prekeyBundle: return 9
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ struct SyncTypeFlags: OptionSet {
|
||||
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] {
|
||||
guard rawValue != 0 else { return [] }
|
||||
var types: [MessageType] = []
|
||||
@@ -150,19 +159,4 @@ struct SyncTypeFlags: OptionSet {
|
||||
}
|
||||
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,10 +227,29 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
final class ChatPrivateConversationCoordinator {
|
||||
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) {
|
||||
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) {
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
@@ -408,10 +427,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
|
||||
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
||||
|
||||
// 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)
|
||||
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
||||
return
|
||||
}
|
||||
@@ -490,7 +514,10 @@ final class ChatPrivateConversationCoordinator {
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
// A stale ack for a message this device no longer tracks (dropped
|
||||
// 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
|
||||
func handleVerifyChallengePayload(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)
|
||||
func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatTransportEventContext {
|
||||
@@ -135,10 +135,6 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
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) {
|
||||
groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||
}
|
||||
@@ -146,6 +142,10 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||
groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||
}
|
||||
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatTransportEventCoordinator {
|
||||
@@ -389,19 +389,14 @@ private extension ChatTransportEventCoordinator {
|
||||
case .verifyResponse:
|
||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||
|
||||
case .vouch:
|
||||
context.handleVouchPayload(from: peerID, payload: payload)
|
||||
|
||||
case .groupInvite:
|
||||
context.handleGroupInvitePayload(from: peerID, payload: payload)
|
||||
|
||||
case .groupKeyUpdate:
|
||||
context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
|
||||
|
||||
case .bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is consumed inside the mesh transport
|
||||
// (BLEService); it never reaches the UI layer.
|
||||
break
|
||||
case .vouch:
|
||||
context.handleVouchPayload(from: peerID, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject {
|
||||
func setStoredVerified(_ fingerprint: String, verified: Bool)
|
||||
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
|
||||
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
|
||||
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
|
||||
@@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
peerIdentityStore.setVerified(fingerprint, verified: verified)
|
||||
}
|
||||
|
||||
func vouchToConnectedVerifiedPeers() {
|
||||
vouchCoordinator.vouchToConnectedVerifiedPeers()
|
||||
}
|
||||
|
||||
var unifiedPeers: [BitchatPeer] {
|
||||
unifiedPeerService.peers
|
||||
}
|
||||
@@ -148,6 +156,9 @@ final class ChatVerificationCoordinator {
|
||||
context.saveIdentityState()
|
||||
context.setStoredVerified(fingerprint, verified: true)
|
||||
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) {
|
||||
@@ -340,6 +351,8 @@ final class ChatVerificationCoordinator {
|
||||
}
|
||||
|
||||
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 mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
|
||||
// Computed properties for compatibility
|
||||
@MainActor
|
||||
@@ -1215,14 +1215,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
GossipMessageArchive.wipeDefault()
|
||||
StoreAndForwardMetrics.shared.reset()
|
||||
|
||||
// Drop private group keys and rosters (keychain + disk)
|
||||
groupStore.wipe()
|
||||
// Drop cached peers' prekey bundles (who we could write to is
|
||||
// metadata too). Our own prekey privates are keychain-backed and go
|
||||
// with deleteAllKeychainData above plus the identity reset below.
|
||||
PrekeyBundleStore.shared.wipe()
|
||||
|
||||
// Drop private group keys and rosters (keychain + disk)
|
||||
groupStore.wipe()
|
||||
|
||||
// Drop bulletin-board posts and tombstones (memory and disk); board
|
||||
// posts are signed with our identity key and persist for days.
|
||||
BoardStore.shared.wipe()
|
||||
@@ -1295,10 +1293,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
// The SPM test process shares the real Application Support tree, so
|
||||
// this detached tree-delete can land mid-test under parallel
|
||||
// scheduling and flake a file-dependent test; tests never need the
|
||||
// on-disk media wiped.
|
||||
// Skipped under tests: the test process shares the user's real
|
||||
// ~/Library/Application Support/files tree, and this detached
|
||||
// utility-priority wipe fires at a nondeterministic time —
|
||||
// deleting media that concurrently running tests (e.g. the
|
||||
// sendImage flow) just wrote there, and the developer's real
|
||||
// app data with it.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
@@ -1650,6 +1650,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||
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
|
||||
@@ -1738,6 +1744,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
func addGeohashOnlySystemMessage(_ content: String) {
|
||||
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.
|
||||
// Used for emotes where we want a local system-style confirmation instead.
|
||||
@MainActor
|
||||
|
||||
@@ -41,7 +41,11 @@ struct ChatViewModelServiceBundle {
|
||||
self.privateChatManager = privateChatManager
|
||||
self.unifiedPeerService = unifiedPeerService
|
||||
self.autocompleteService = AutocompleteService()
|
||||
self.deduplicationService = MessageDeduplicationService()
|
||||
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ protocol ChatVouchContext: AnyObject {
|
||||
|
||||
// MARK: Transport
|
||||
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
|
||||
/// verification coordinator's callbacks).
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
|
||||
@@ -74,6 +77,10 @@ extension ChatViewModel: ChatVouchContext {
|
||||
meshService.peerCapabilities(peerID)
|
||||
}
|
||||
|
||||
func connectedPeerIDs() -> [PeerID] {
|
||||
Array(connectedPeers)
|
||||
}
|
||||
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
meshService.addPeerAuthenticatedObserver(handler)
|
||||
}
|
||||
@@ -120,16 +127,70 @@ final class ChatVouchCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange policy: on session establishment with a peer I verified that
|
||||
/// advertises the `.vouch` capability, send attestations for up to
|
||||
/// `VouchAttestation.maxBatchCount` *other* verified fingerprints (most
|
||||
/// recently verified first), at most once per peer per `batchInterval`.
|
||||
/// Trigger — session established: on a Noise session coming up with a peer
|
||||
/// I verified, attempt to send a vouch batch. Kept as the historical entry
|
||||
/// point; the real work lives in `attemptVouch`.
|
||||
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
|
||||
guard context.isVerifiedFingerprint(fingerprint) else { return }
|
||||
guard context.peerCapabilities(for: peerID).contains(.vouch) else { return }
|
||||
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
now.timeIntervalSince(lastSent) < Self.batchInterval {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
let candidates = context.recentlyVerifiedFingerprints(
|
||||
@@ -156,13 +217,14 @@ final class ChatVouchCoordinator {
|
||||
}
|
||||
|
||||
guard !attestations.isEmpty,
|
||||
let payload = VouchAttestation.encodeList(attestations) else { return }
|
||||
let payload = VouchAttestation.encodeList(attestations) else { return false }
|
||||
context.sendVouchAttestations(payload, to: peerID)
|
||||
context.markVouchBatchSent(to: fingerprint, at: now)
|
||||
SecureLogger.debug(
|
||||
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Accept policy: process inbound vouches only from a sender I verified,
|
||||
|
||||
@@ -304,10 +304,8 @@ final class NostrInboundPipeline {
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk
|
||||
// negotiation is mesh-proximity only; it never rides Nostr either.
|
||||
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -360,10 +358,8 @@ final class NostrInboundPipeline {
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored. Wi-Fi bulk
|
||||
// negotiation is mesh-proximity only; it never rides Nostr either.
|
||||
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -443,11 +439,8 @@ final class NostrInboundPipeline {
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
// Group state travels only over mesh Noise sessions
|
||||
// in v1; group traffic over Nostr is ignored. Wi-Fi
|
||||
// bulk negotiation is mesh-proximity only; it never
|
||||
// rides Nostr either.
|
||||
case .verifyChallenge, .verifyResponse, .vouch, .groupInvite, .groupKeyUpdate,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// in v1; group traffic over Nostr is ignored.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+21
-148
@@ -1,12 +1,4 @@
|
||||
import SwiftUI
|
||||
#if DEBUG
|
||||
import BitLogger
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
#endif
|
||||
|
||||
struct AppInfoView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -18,14 +10,6 @@ struct AppInfoView: View {
|
||||
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
|
||||
@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 {
|
||||
AppTheme(rawValue: appThemeRawValue) ?? .matrix
|
||||
}
|
||||
@@ -239,6 +223,27 @@ struct AppInfoView: View {
|
||||
.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
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
@@ -278,27 +283,6 @@ 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
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
@@ -309,122 +293,11 @@ struct AppInfoView: View {
|
||||
|
||||
FeatureRow(info: Strings.Privacy.panic)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
debugSection
|
||||
#endif
|
||||
}
|
||||
.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 {
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
//
|
||||
// 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,21 +1,17 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct ContentHeaderView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@EnvironmentObject private var boardAlertsModel: BoardAlertsModel
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
@Binding var showVerifySheet: Bool
|
||||
@Binding var showLocationNotes: Bool
|
||||
@Binding var notesGeohash: String?
|
||||
var isNicknameFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
let headerHeight: CGFloat
|
||||
@@ -25,8 +21,13 @@ struct ContentHeaderView: View {
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
/// Bulletin board sheet for the current channel context.
|
||||
@State private var showBoard = false
|
||||
/// Unified notices sheet (board posts + location notes) for the current
|
||||
/// channel context.
|
||||
@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 {
|
||||
HStack(spacing: 0) {
|
||||
@@ -137,36 +138,40 @@ struct ContentHeaderView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if case .mesh = locationChannelsModel.selectedChannel,
|
||||
locationChannelsModel.permissionState == .authorized {
|
||||
Button(action: {
|
||||
locationChannelsModel.enableAndRefresh()
|
||||
notesGeohash = locationChannelsModel.currentBuildingGeohash
|
||||
showLocationNotes = true
|
||||
}) {
|
||||
Image(systemName: "note.text")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(Color.orange.opacity(0.8))
|
||||
.headerTapTarget()
|
||||
Button(action: {
|
||||
var scopes: Set<String> = [""]
|
||||
if let geoScope = noticesGeoScope {
|
||||
scopes.insert(geoScope)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { showBoard = true }) {
|
||||
Image(systemName: "pin")
|
||||
boardAlertsModel.markSeen(forScopes: scopes)
|
||||
showNotices = true
|
||||
}) {
|
||||
// Fill marks unseen new pins; the tint says the current
|
||||
// scope has notices at all.
|
||||
Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.foregroundColor(
|
||||
scopeHasNotices || unseenNoticesCount > 0
|
||||
? Color.orange.opacity(0.8)
|
||||
: palette.secondary.opacity(0.9)
|
||||
)
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
|
||||
String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices 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(
|
||||
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
|
||||
String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button")
|
||||
)
|
||||
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
@@ -267,54 +272,21 @@ struct ContentHeaderView: View {
|
||||
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||
carriedMailCount = count
|
||||
}
|
||||
.onReceive(BoardStore.shared.$postsSnapshot) { posts in
|
||||
boardPosts = posts
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||
.environmentObject(locationChannelsModel)
|
||||
.environmentObject(peerListModel)
|
||||
}
|
||||
.sheet(isPresented: $showBoard) {
|
||||
BoardView(
|
||||
geohash: boardGeohash,
|
||||
.sheet(isPresented: $showNotices) {
|
||||
NoticesView(
|
||||
senderNickname: appChromeModel.nickname,
|
||||
board: appChromeModel.boardManager
|
||||
board: appChromeModel.boardManager,
|
||||
initialTab: initialNoticesTab
|
||||
)
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
.environmentObject(locationChannelsModel)
|
||||
}
|
||||
.onAppear {
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
@@ -348,13 +320,34 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// The board scope for the current channel: the geohash channel's board,
|
||||
/// or the mesh-local board ("") in mesh chat.
|
||||
var boardGeohash: String {
|
||||
/// Open the notices sheet on the tab matching the current channel: the
|
||||
/// geohash channel's notices, or the mesh-local board in mesh chat.
|
||||
var initialNoticesTab: NoticesView.Tab {
|
||||
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 {
|
||||
return channel.geohash
|
||||
}
|
||||
return ""
|
||||
return locationChannelsModel.currentBuildingGeohash
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -380,37 +373,3 @@ 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,8 +49,6 @@ struct ContentView: View {
|
||||
@State private var isAtBottomPrivate = true
|
||||
@State private var autocompleteDebounceTimer: Timer?
|
||||
@State private var showVerifySheet = false
|
||||
@State private var showLocationNotes = false
|
||||
@State private var notesGeohash: String?
|
||||
@State private var imagePreviewURL: URL?
|
||||
#if os(iOS)
|
||||
@State private var showImagePicker = false
|
||||
@@ -269,8 +267,6 @@ struct ContentView: View {
|
||||
ContentHeaderView(
|
||||
showSidebar: $showSidebar,
|
||||
showVerifySheet: $showVerifySheet,
|
||||
showLocationNotes: $showLocationNotes,
|
||||
notesGeohash: $notesGeohash,
|
||||
isNicknameFieldFocused: $isNicknameFieldFocused,
|
||||
headerHeight: headerHeight,
|
||||
headerPeerIconSize: headerPeerIconSize,
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
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
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
//
|
||||
// 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,51 +217,6 @@ 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.
|
||||
/// The pong budget must be keyed on the ingress link (the directly
|
||||
/// connected peer that delivered the packet): rotating forged sender IDs
|
||||
|
||||
@@ -157,12 +157,6 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
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
|
||||
private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
@@ -174,6 +168,12 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
|
||||
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
|
||||
|
||||
@@ -51,6 +51,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
|
||||
func saveIdentityState() { saveIdentityStateCount += 1 }
|
||||
|
||||
private(set) var vouchToConnectedVerifiedPeersCount = 0
|
||||
func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 }
|
||||
|
||||
// Encryption status
|
||||
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
|
||||
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
|
||||
|
||||
@@ -60,8 +60,12 @@ private final class MockChatVouchContext: ChatVouchContext {
|
||||
private(set) var installedObservers: [(PeerID, String) -> Void] = []
|
||||
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
|
||||
|
||||
var connectedPeerIDList: [PeerID] = []
|
||||
|
||||
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
|
||||
|
||||
func connectedPeerIDs() -> [PeerID] { connectedPeerIDList }
|
||||
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
installedObservers.append(handler)
|
||||
}
|
||||
@@ -135,14 +139,78 @@ struct ChatVouchCoordinatorContextTests {
|
||||
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
||||
#expect(context.sentVouchPayloads.isEmpty)
|
||||
|
||||
// Verified but no .vouch capability: nothing.
|
||||
// Verified but advertises a non-empty capability set lacking .vouch:
|
||||
// nothing. (An *empty*/unknown set is race-tolerant and still sends —
|
||||
// see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
|
||||
context.verifiedFingerprints.insert(peerFingerprint)
|
||||
context.capabilitiesByPeerID[peerID] = []
|
||||
context.capabilitiesByPeerID[peerID] = [.prekeys]
|
||||
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
||||
#expect(context.sentVouchPayloads.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
|
||||
func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
|
||||
let (context, coordinator) = makeVerifiedCapablePeer()
|
||||
|
||||
@@ -370,7 +370,6 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
// Silence the prekey round so the maintenance barrier below emits nothing.
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
@@ -545,7 +544,6 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
// Silence the prekey round so the maintenance barrier isolates fragments.
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
@@ -588,6 +586,30 @@ struct GossipSyncManagerTests {
|
||||
#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 {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
@@ -675,31 +697,6 @@ struct GossipSyncManagerTests {
|
||||
#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
|
||||
|
||||
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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,14 +40,17 @@ struct BLEFragmentHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func ownFragmentIsIgnored() {
|
||||
func ownFragmentIsTrackedForSyncButNotAssembled() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2)
|
||||
|
||||
handler.handle(packet, from: localPeerID)
|
||||
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
// Sync replay hands own fragments back after a relaunch; they must
|
||||
// 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.reinjectedPackets.isEmpty)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// 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 decide(
|
||||
private func route(
|
||||
packet: BitchatPacket,
|
||||
isRecipientConnected: Bool = false,
|
||||
shouldAttemptRoute: Bool = true,
|
||||
computedRoute: [Data]? = nil
|
||||
) -> BLESourceRouteOriginationPolicy.Decision {
|
||||
BLESourceRouteOriginationPolicy.decide(
|
||||
) -> [Data]? {
|
||||
BLESourceRouteOriginationPolicy.route(
|
||||
for: packet,
|
||||
to: recipient,
|
||||
localPeerIDData: localPeerIDData,
|
||||
@@ -49,38 +49,38 @@ struct BLESourceRouteOriginationPolicyTests {
|
||||
}
|
||||
|
||||
@Test func routesWhenAllGatesPass() {
|
||||
#expect(decide(packet: makePacket()) == .route([hop]))
|
||||
#expect(route(packet: makePacket()) == [hop])
|
||||
}
|
||||
|
||||
@Test func relayedPacketNeverGetsRoute() {
|
||||
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
|
||||
#expect(decide(packet: relayed) == .flood(.relayedNotOriginator))
|
||||
#expect(route(packet: relayed) == nil)
|
||||
}
|
||||
|
||||
@Test func broadcastRecipientNeverGetsRoute() {
|
||||
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
|
||||
#expect(decide(packet: broadcast) == .flood(.broadcast))
|
||||
#expect(route(packet: broadcast) == nil)
|
||||
let noRecipient = makePacket(recipientID: nil)
|
||||
#expect(decide(packet: noRecipient) == .flood(.broadcast))
|
||||
#expect(route(packet: noRecipient) == nil)
|
||||
}
|
||||
|
||||
@Test func linkLocalTTLNeverGetsRoute() {
|
||||
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
|
||||
#expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom))
|
||||
#expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom))
|
||||
#expect(route(packet: makePacket(ttl: 0)) == nil)
|
||||
#expect(route(packet: makePacket(ttl: 1)) == nil)
|
||||
}
|
||||
|
||||
@Test func directlyConnectedRecipientNeverGetsRoute() {
|
||||
#expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect))
|
||||
#expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
|
||||
}
|
||||
|
||||
@Test func suppressedRecipientFallsBackToFlood() {
|
||||
#expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed))
|
||||
#expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
|
||||
}
|
||||
|
||||
@Test func missingOrEmptyRouteFallsBackToFlood() {
|
||||
var sawComputeRoute = false
|
||||
let result = BLESourceRouteOriginationPolicy.decide(
|
||||
let result = BLESourceRouteOriginationPolicy.route(
|
||||
for: makePacket(),
|
||||
to: recipient,
|
||||
localPeerIDData: localPeerIDData,
|
||||
@@ -91,8 +91,8 @@ struct BLESourceRouteOriginationPolicyTests {
|
||||
return nil
|
||||
}
|
||||
)
|
||||
#expect(result == .flood(.noPath))
|
||||
#expect(result == nil)
|
||||
#expect(sawComputeRoute)
|
||||
#expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath))
|
||||
#expect(route(packet: makePacket(), computedRoute: []) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// 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,6 +337,33 @@ struct GatewayServiceTests {
|
||||
#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")
|
||||
func forgedBroadcastDoesNotPoison() throws {
|
||||
let fixture = Fixture()
|
||||
|
||||
@@ -277,14 +277,14 @@ private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
func clearPrivateChat(_ peerID: PeerID) {}
|
||||
func sendPublicRaw(_ 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 groupInvite(nickname: String) -> CommandResult { .handled }
|
||||
func groupRemove(nickname: String) -> CommandResult { .handled }
|
||||
func groupLeave() -> CommandResult { .handled }
|
||||
func groupList() -> CommandResult { .handled }
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
||||
func addPublicSystemMessage(_ content: String) {}
|
||||
func toggleFavorite(peerID: PeerID) {}
|
||||
|
||||
func currentCommandDestination() -> CommandOutputDestination {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
|
||||
@@ -111,6 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
permissionProvider: { permissionSubject.value },
|
||||
mutualFavoritesProvider: { favoritesSubject.value },
|
||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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) }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// 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,8 +34,6 @@ struct GossipSyncBoardTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 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
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ struct SyncTypeFlagsBoardTests {
|
||||
/// 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".
|
||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||
// Bits 11-15 are unassigned after the feature integration (bit 8 is
|
||||
// board, 9 prekey, 10 group); a future (or unknown) two-byte bitfield
|
||||
// must decode without error and yield no known types.
|
||||
// Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle,
|
||||
// bit 10 = groupMessage); a future (or unknown) two-byte bitfield must
|
||||
// decode without error and yield no known types.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,33 +13,43 @@ struct SyncTypeFlagsTests {
|
||||
}
|
||||
|
||||
@Test func decodeDropsPhantomBits() {
|
||||
// Bits 11+ map to no message type (bits 8/9/10 are board/prekey/group
|
||||
// after the feature integration). They must not survive decode as
|
||||
// phantom membership.
|
||||
// Bits 11+ map to no message type (bit 8 = boardPost, bit 9 =
|
||||
// prekeyBundle, bit 10 = groupMessage). They must not survive decode
|
||||
// as phantom membership.
|
||||
let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type
|
||||
let decoded = SyncTypeFlags.decode(phantom)
|
||||
#expect(decoded?.rawValue == 0)
|
||||
#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() {
|
||||
// Low byte = announce(0) + message(1); high byte = phantom (bits 11+,
|
||||
// which map to no known type).
|
||||
// Low byte = announce(0) + message(1); high byte bits 11+ are phantom.
|
||||
let mixed = Data([0b0000_0011, 0xF8])
|
||||
let decoded = SyncTypeFlags.decode(mixed)
|
||||
#expect(decoded?.contains(.announce) == true)
|
||||
#expect(decoded?.contains(.message) == true)
|
||||
// Only the two known bits remain; phantom high byte is gone.
|
||||
// Only the two known bits remain; phantom high bits are gone.
|
||||
#expect(decoded?.rawValue == 0b0000_0011)
|
||||
}
|
||||
|
||||
@Test func rawValueInitNormalizesPhantomBits() {
|
||||
let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF)
|
||||
// Every known type bit is set; nothing above them survives. The
|
||||
// highest known bit is 10 (groupMessage), so the field serializes to
|
||||
// two bytes.
|
||||
// Every known type bit is set; nothing above them survives. boardPost
|
||||
// occupies bit 8, so the known set spills into a second byte.
|
||||
#expect(flags.contains(.announce))
|
||||
#expect(flags.contains(.fileTransfer))
|
||||
#expect(flags.contains(.board))
|
||||
let data = flags.toData()
|
||||
#expect(data?.count == 2)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@@ -39,6 +40,7 @@ private struct SmokeFeatureModels {
|
||||
let verificationModel: VerificationModel
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let peerListModel: PeerListModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
let boardAlertsModel = BoardAlertsModel(
|
||||
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
|
||||
dependencies: BoardAlertsModel.Dependencies(
|
||||
isOwnPost: { _ in false },
|
||||
emitSystemLine: { _, _ in }
|
||||
)
|
||||
)
|
||||
|
||||
return SmokeFeatureModels(
|
||||
publicChatModel: publicChatModel,
|
||||
appChromeModel: appChromeModel,
|
||||
@@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
||||
privateConversationModel: privateConversationModel,
|
||||
verificationModel: verificationModel,
|
||||
conversationUIModel: conversationUIModel,
|
||||
peerListModel: peerListModel
|
||||
peerListModel: peerListModel,
|
||||
boardAlertsModel: boardAlertsModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +117,7 @@ private func installSmokeEnvironment<V: View>(
|
||||
.environmentObject(featureModels.verificationModel)
|
||||
.environmentObject(featureModels.conversationUIModel)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
.environmentObject(featureModels.boardAlertsModel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -395,9 +407,17 @@ struct ViewSmokeTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
func noticesView_rendersNoRelayAndLoadedStates() throws {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
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(
|
||||
geohash: "u4pruydq",
|
||||
@@ -440,18 +460,28 @@ struct ViewSmokeTests {
|
||||
eose?()
|
||||
|
||||
_ = mount(
|
||||
LocationNotesView(
|
||||
geohash: "u4pruydq",
|
||||
NoticesView(
|
||||
senderNickname: viewModel.nickname,
|
||||
manager: noRelayManager
|
||||
board: board,
|
||||
initialTab: .geo,
|
||||
notesManager: noRelayManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
_ = mount(
|
||||
LocationNotesView(
|
||||
geohash: "u4pruydq",
|
||||
NoticesView(
|
||||
senderNickname: viewModel.nickname,
|
||||
manager: loadedManager
|
||||
board: board,
|
||||
initialTab: .geo,
|
||||
notesManager: loadedManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
_ = mount(
|
||||
NoticesView(
|
||||
senderNickname: viewModel.nickname,
|
||||
board: board,
|
||||
initialTab: .mesh
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
//
|
||||
// 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,6 +75,11 @@ public final class TorManager: ObservableObject {
|
||||
}
|
||||
|
||||
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 pathMonitor: NWPathMonitor?
|
||||
private var isAppForeground: Bool = true
|
||||
@@ -90,6 +95,11 @@ public final class TorManager: ObservableObject {
|
||||
public func startIfNeeded() {
|
||||
guard allowAutoStart 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 }
|
||||
didStart = true
|
||||
isDormant = false
|
||||
@@ -329,6 +339,8 @@ public final class TorManager: ObservableObject {
|
||||
|
||||
public func shutdownCompletely() {
|
||||
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
||||
startPendingAfterShutdown = false
|
||||
shutdownsInFlight += 1
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
_ = arti_stop()
|
||||
@@ -352,6 +364,12 @@ public final class TorManager: ObservableObject {
|
||||
self.bootstrapMonitorStarted = false
|
||||
// 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
|
||||
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,10 +2,6 @@
|
||||
public enum FileTransferLimits {
|
||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||
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.
|
||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
@@ -21,7 +17,7 @@ public enum FileTransferLimits {
|
||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||
}()
|
||||
|
||||
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool {
|
||||
size <= limit
|
||||
public static func isValidPayload(_ size: Int) -> Bool {
|
||||
size <= maxPayloadBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,18 @@ public enum MessageType: UInt8 {
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||
case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
|
||||
|
||||
// Mesh diagnostics
|
||||
case ping = 0x26 // Directed echo request (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
|
||||
// an internet gateway peer.
|
||||
case nostrCarrier = 0x28
|
||||
|
||||
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
@@ -1,104 +0,0 @@
|
||||
//
|
||||
// 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,11 +20,4 @@ public extension OSLog {
|
||||
static let security = OSLog(subsystem: subsystem, category: "security")
|
||||
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
|
||||
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,17 +86,6 @@ public final class SecureLogger {
|
||||
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 {
|
||||
switch self {
|
||||
@@ -111,34 +100,18 @@ public final class SecureLogger {
|
||||
|
||||
// MARK: - Global Threshold
|
||||
|
||||
/// 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`).
|
||||
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
|
||||
/// Internal-settable so tests can verify level filtering; app code should not mutate it.
|
||||
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 {
|
||||
internal static var minimumLevel: LogLevel = {
|
||||
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
|
||||
switch env {
|
||||
case "debug": return .debug
|
||||
case "info": return .info
|
||||
case "warning": return .warning
|
||||
case "error": return .error
|
||||
case "fault": return .fault
|
||||
default:
|
||||
#if DEBUG
|
||||
return .debug
|
||||
#else
|
||||
return .info
|
||||
#endif
|
||||
default: return .info
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
private static func shouldLog(_ level: LogLevel) -> Bool {
|
||||
return level.order >= minimumLevel.order
|
||||
@@ -195,7 +168,6 @@ public extension SecureLogger {
|
||||
|
||||
#if DEBUG
|
||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)")
|
||||
#else
|
||||
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||
#endif
|
||||
@@ -286,10 +258,9 @@ private extension SecureLogger {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let sanitized = "\(location) \(message())".sanitized()
|
||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||
record(level: level, message: sanitized)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
/// Log a security event
|
||||
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
|
||||
file: String, line: Int, function: String) {
|
||||
@@ -298,18 +269,6 @@ private extension SecureLogger {
|
||||
let location = formatLocation(file: file, line: line, function: function)
|
||||
let message = "\(location) \(event.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
|
||||
}
|
||||
|
||||
|
||||
@@ -51,25 +51,4 @@ final class LogLevelFilteringTests: XCTestCase {
|
||||
|
||||
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