mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:05:19 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f02319f6f | ||
|
|
970802c923 | ||
|
|
935dcdbd51 | ||
|
|
bf5c9a3850 | ||
|
|
9cbfd131da | ||
|
|
20255c9916 | ||
|
|
b62f98cc62 | ||
|
|
02aa1d67db | ||
|
|
e0e90af9fd | ||
|
|
94e7f91b7b | ||
|
|
00c7f0460a | ||
|
|
175da268dd | ||
|
|
f6f7fa704a | ||
|
|
4f5d25808f | ||
|
|
a05c583fb5 | ||
|
|
25efa62587 | ||
|
|
0d93ce0874 | ||
|
|
20cdd6653a | ||
|
|
862484c0d4 | ||
|
|
e1e481a1a7 | ||
|
|
aed3e6a273 | ||
|
|
b481a70458 | ||
|
|
2b7fd2002b | ||
|
|
ccccb2dd8c | ||
|
|
385e9e2aab | ||
|
|
48035872e1 | ||
|
|
d4594b9504 | ||
|
|
9ae440be6a | ||
|
|
fd2dffdda7 | ||
|
|
953d24f7f2 | ||
|
|
6dd8dd055a | ||
|
|
85112d809b | ||
|
|
e4b3cff5fa | ||
|
|
fb80403cd4 | ||
|
|
7ad19ab4c3 | ||
|
|
b1ff5e6b82 | ||
|
|
ee148a018b | ||
|
|
26b247c329 | ||
|
|
81168adad1 | ||
|
|
18dcc747d7 | ||
|
|
6056d20a13 | ||
|
|
112c0ec1ed | ||
|
|
688b954fb8 | ||
|
|
f5dde45c18 |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.6.0
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -28,7 +28,6 @@ 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>()
|
||||
@@ -92,24 +91,6 @@ 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()
|
||||
@@ -368,12 +349,12 @@ private extension AppRuntime {
|
||||
TorManager.shared.isAutoStartAllowed() {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
|
||||
)
|
||||
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||
String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ final class PrivateConversationModel: ObservableObject {
|
||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||
displayName = "#\(group.name) (\(group.members.count))"
|
||||
} else {
|
||||
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
displayName = String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
@@ -328,7 +328,7 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
|
||||
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||
|
||||
@@ -186,6 +186,6 @@ final class VerificationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
#if DEBUG
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
@@ -40,10 +43,14 @@ 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,12 +33,18 @@
|
||||
<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>
|
||||
|
||||
+1755
-7884
File diff suppressed because it is too large
Load Diff
@@ -36,11 +36,11 @@ enum CommandInfo: String, Identifiable {
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">"
|
||||
case .group:
|
||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||
return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||
return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">"
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
@@ -48,20 +48,20 @@ enum CommandInfo: String, Identifiable {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .group: String(localized: "content.commands.group")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .pay: String(localized: "content.commands.pay")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .ping: String(localized: "content.commands.ping")
|
||||
case .trace: String(localized: "content.commands.trace")
|
||||
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers")
|
||||
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages")
|
||||
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups")
|
||||
case .help: String(localized: "content.commands.help", defaultValue: "show available commands")
|
||||
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug")
|
||||
case .message: String(localized: "content.commands.message", defaultValue: "send private message")
|
||||
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat")
|
||||
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout")
|
||||
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer")
|
||||
case .who: String(localized: "content.commands.who", defaultValue: "see who's online")
|
||||
case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites")
|
||||
case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer")
|
||||
case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 work: mesh.
|
||||
// The processor rejects favorites, groups and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ struct NostrProtocol {
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -257,22 +256,16 @@ struct NostrProtocol {
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
/// 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,
|
||||
expiresAt: Date? = nil
|
||||
nickname: String? = 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(),
|
||||
@@ -283,25 +276,7 @@ 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,12 +28,14 @@ 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).
|
||||
func encode() -> Data? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
if size > UInt64(limit) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
if proposedSize > limit {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
|
||||
@@ -74,7 +74,10 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Private groups (0x04/0x05 reserved by other features)
|
||||
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
||||
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
||||
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
||||
// Private groups
|
||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Verification (QR-based OOB binding)
|
||||
@@ -88,6 +91,8 @@ 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,14 +18,6 @@ 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)
|
||||
|
||||
@@ -24,17 +24,17 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .building:
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,9 @@ 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 = [.vouch, .prekeys, .groups]
|
||||
static let localSupported: PeerCapabilities = {
|
||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||
return caps
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling that was enforced against the accepted offer.
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||
|
||||
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
|
||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
/// `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 {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
|
||||
@@ -33,21 +33,13 @@ final class BLEFragmentHandler {
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
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.
|
||||
// Don't process our own fragments
|
||||
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, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||
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.
|
||||
// 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.
|
||||
// Ping/pong diagnostics also ride it: probes need the same
|
||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
|| packet.type == MessageType.pong.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,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
|
||||
/// links) so the service can arm pending background connections against them
|
||||
/// when the app leaves the foreground. Generic over the peripheral type so
|
||||
/// the eviction/expiry logic is testable without CoreBluetooth.
|
||||
final class BLERecentPeripheralCache<Peripheral> {
|
||||
private struct Entry {
|
||||
let peripheral: Peripheral
|
||||
var lastSeen: Date
|
||||
}
|
||||
|
||||
private var entries: [String: Entry] = [:]
|
||||
private let capacity: Int
|
||||
private let maxAge: TimeInterval
|
||||
|
||||
init(
|
||||
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
|
||||
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
|
||||
) {
|
||||
self.capacity = capacity
|
||||
self.maxAge = maxAge
|
||||
}
|
||||
|
||||
var count: Int { entries.count }
|
||||
|
||||
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
|
||||
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
|
||||
guard entries.count > capacity else { return }
|
||||
// Inserts overshoot capacity by at most one; evict the stalest entry
|
||||
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
|
||||
entries.removeValue(forKey: stalest.key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most-recently-seen peripherals eligible for a pending background
|
||||
/// connect, freshest first, capped at `limit`. Expired entries are
|
||||
/// pruned as a side effect.
|
||||
func reconnectTargets(
|
||||
now: Date,
|
||||
limit: Int,
|
||||
excluding: (String) -> Bool
|
||||
) -> [(peripheralID: String, peripheral: Peripheral)] {
|
||||
let cutoff = now.addingTimeInterval(-maxAge)
|
||||
entries = entries.filter { $0.value.lastSeen >= cutoff }
|
||||
guard limit > 0 else { return [] }
|
||||
return entries
|
||||
.filter { !excluding($0.key) }
|
||||
.sorted { $0.value.lastSeen > $1.value.lastSeen }
|
||||
.prefix(limit)
|
||||
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,6 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
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
|
||||
@@ -111,6 +109,10 @@ 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()
|
||||
@@ -188,6 +190,8 @@ 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?
|
||||
@@ -197,7 +201,6 @@ 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,
|
||||
@@ -208,9 +211,6 @@ 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,6 +316,12 @@ 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() {
|
||||
@@ -564,6 +570,9 @@ 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,
|
||||
@@ -783,6 +792,9 @@ 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 }
|
||||
|
||||
@@ -858,10 +870,72 @@ final class BLEService: NSObject {
|
||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let payload = filePacket.encode() else {
|
||||
// Encode with the Wi-Fi bulk ceiling; whether the payload also
|
||||
// fits the BLE caps decides what a fallback may do.
|
||||
guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else {
|
||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||
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()
|
||||
@@ -890,6 +964,13 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the progress bus through started→cancelled so the UI clears a
|
||||
/// transfer that no transport can carry.
|
||||
private func surfaceUndeliverableTransfer(_ transferId: String) {
|
||||
TransferProgressManager.shared.start(id: transferId, totalFragments: 1)
|
||||
TransferProgressManager.shared.cancel(id: transferId)
|
||||
}
|
||||
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||||
@@ -1275,6 +1356,57 @@ final class BLEService: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the Wi-Fi bulk service environment. Noise encryption and packet
|
||||
/// dispatch stay on the message queue; incoming payloads re-enter the
|
||||
/// normal file-transfer pipeline as if a fully assembled packet arrived.
|
||||
private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment {
|
||||
WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { [weak self] typedPayload, peerID in
|
||||
guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false }
|
||||
self.messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { [weak self] peerID in
|
||||
self?.isPeerConnected(peerID) ?? false
|
||||
},
|
||||
deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in
|
||||
self?.messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: Data(hexString: peerID.toShort().id) ?? Data(),
|
||||
recipientID: self.myPeerIDData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 0,
|
||||
version: 2
|
||||
)
|
||||
self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit)
|
||||
}
|
||||
},
|
||||
progressStart: { transferId, totalChunks in
|
||||
TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks)
|
||||
},
|
||||
progressChunkSent: { transferId in
|
||||
TransferProgressManager.shared.recordFragmentSent(id: transferId)
|
||||
},
|
||||
progressReset: { transferId in
|
||||
TransferProgressManager.shared.reset(id: transferId)
|
||||
},
|
||||
progressCancel: { transferId in
|
||||
TransferProgressManager.shared.cancel(id: transferId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||||
|
||||
@@ -1519,14 +1651,6 @@ 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")
|
||||
@@ -1542,17 +1666,6 @@ 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()
|
||||
|
||||
@@ -1632,9 +1745,6 @@ 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(
|
||||
@@ -1661,15 +1771,7 @@ 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)
|
||||
|
||||
@@ -1694,26 +1796,7 @@ 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 {
|
||||
@@ -1841,18 +1924,6 @@ 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)
|
||||
@@ -1932,6 +2003,34 @@ 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
|
||||
|
||||
@@ -2604,7 +2703,7 @@ extension BLEService {
|
||||
|
||||
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
||||
let now = Date()
|
||||
let route = BLESourceRouteOriginationPolicy.route(
|
||||
let decision = BLESourceRouteOriginationPolicy.decide(
|
||||
for: packet,
|
||||
to: recipient,
|
||||
localPeerIDData: myPeerIDData,
|
||||
@@ -2616,7 +2715,19 @@ extension BLEService {
|
||||
},
|
||||
computeRoute: { self.computeRoute(to: $0) }
|
||||
)
|
||||
guard let route else { return packet }
|
||||
let route: [Data]
|
||||
switch decision {
|
||||
case .flood(let reason):
|
||||
// Only log the interesting directed cases; a plain broadcast keeps
|
||||
// flood by design and would spam the origination path.
|
||||
if reason != .broadcast {
|
||||
SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh)
|
||||
}
|
||||
return packet
|
||||
case .route(let hops):
|
||||
SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh)
|
||||
route = hops
|
||||
}
|
||||
// Create new packet with route applied and version upgraded to 2
|
||||
let routedPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
@@ -2706,7 +2817,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("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))…", category: .mesh)
|
||||
return
|
||||
}
|
||||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -2714,7 +2825,7 @@ extension BLEService {
|
||||
}
|
||||
guard allowed else {
|
||||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
||||
SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .mesh)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2748,6 +2859,7 @@ 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) }
|
||||
}
|
||||
|
||||
@@ -2756,11 +2868,15 @@ extension BLEService {
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
refreshLocalTopology()
|
||||
if let route = computeRoute(to: peerID) {
|
||||
return route.compactMap { PeerID(routingData: $0) }
|
||||
let path = route.compactMap { PeerID(routingData: $0) }
|
||||
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh)
|
||||
return path
|
||||
}
|
||||
// Confirmed claims can lag a brand-new link (the peer's next announce
|
||||
// hasn't arrived yet); a live direct connection is still a known path.
|
||||
return isPeerConnected(peerID) ? [] : nil
|
||||
let direct = isPeerConnected(peerID)
|
||||
SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… \(direct ? "direct (0 hops)" : "no known path")", category: .mesh)
|
||||
return direct ? [] : nil
|
||||
}
|
||||
|
||||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||||
@@ -2906,9 +3022,11 @@ 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(
|
||||
@@ -3002,6 +3120,7 @@ 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 {
|
||||
@@ -3233,7 +3352,7 @@ extension BLEService {
|
||||
return nil
|
||||
}
|
||||
guard let signingKey else {
|
||||
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
|
||||
SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||||
@@ -3244,11 +3363,13 @@ extension BLEService {
|
||||
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
|
||||
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
|
||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
|
||||
SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
if prekeyBundleStore.ingest(bundle) {
|
||||
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
|
||||
SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))…", category: .session)
|
||||
}
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
@@ -3472,12 +3593,11 @@ 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
|
||||
@@ -3485,7 +3605,6 @@ extension BLEService {
|
||||
centralManager?.stopScan()
|
||||
startScanning()
|
||||
}
|
||||
armPendingBackgroundConnects()
|
||||
// Backgrounding may precede a kill; flush the public-history archive
|
||||
// outside its 30s maintenance cadence.
|
||||
gossipSyncManager?.persistNow()
|
||||
@@ -3493,81 +3612,6 @@ 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
|
||||
@@ -3675,7 +3719,7 @@ extension BLEService {
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", defaultValue: "encryption failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3916,16 +3960,6 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// The maintenance timer is suspended with the app, so a packet arriving
|
||||
// while backgrounded means the radio woke us — use the wake window to
|
||||
// run the announce/flush/drain pass the timer would have run.
|
||||
if !isAppActive {
|
||||
bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
// Process by type
|
||||
switch context.messageType {
|
||||
case .announce:
|
||||
@@ -3952,15 +3986,12 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
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 .groupMessage:
|
||||
handleGroupMessage(packet, from: senderID)
|
||||
|
||||
case .nostrCarrier:
|
||||
handleNostrCarrier(packet, from: peerID)
|
||||
|
||||
@@ -3973,6 +4004,10 @@ 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)
|
||||
|
||||
@@ -4356,8 +4391,21 @@ 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,
|
||||
@@ -4422,7 +4470,6 @@ extension BLEService {
|
||||
|
||||
private func performMaintenance() {
|
||||
maintenanceCounter += 1
|
||||
lastMaintenanceAt = Date()
|
||||
|
||||
let now = Date()
|
||||
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
||||
@@ -4487,18 +4534,6 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Catch-up maintenance for background wake windows (bleQueue-confined).
|
||||
/// Rate-limited to the normal maintenance cadence so a burst of inbound
|
||||
/// packets during one wake still runs at most one extra pass.
|
||||
private func performBackgroundWakeMaintenanceIfStale() {
|
||||
guard meshBackgroundEnabled,
|
||||
!isAppActive,
|
||||
Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
|
||||
performMaintenance()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func checkPeerConnectivity() {
|
||||
let now = Date()
|
||||
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
|
||||
|
||||
@@ -4,8 +4,27 @@ 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 {
|
||||
/// Returns the intermediate-hop route to attach, or nil to keep the
|
||||
/// current flood/direct-write behavior unchanged.
|
||||
/// 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.
|
||||
///
|
||||
/// Routes are only originated when every gate passes:
|
||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||
@@ -19,22 +38,22 @@ enum BLESourceRouteOriginationPolicy {
|
||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||
/// routed send, and
|
||||
/// - the topology yields a complete path.
|
||||
static func route(
|
||||
static func decide(
|
||||
for packet: BitchatPacket,
|
||||
to recipient: PeerID,
|
||||
localPeerIDData: Data,
|
||||
isRecipientConnected: (PeerID) -> Bool,
|
||||
shouldAttemptRoute: (PeerID) -> Bool,
|
||||
computeRoute: (PeerID) -> [Data]?
|
||||
) -> [Data]? {
|
||||
guard packet.senderID == localPeerIDData else { return nil }
|
||||
) -> Decision {
|
||||
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
|
||||
guard let recipientData = packet.recipientID,
|
||||
recipientData.count == 8,
|
||||
!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
|
||||
!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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
//
|
||||
// BoardAlertsModel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Turns newly arriving board posts into local, scope-matched chat alerts.
|
||||
/// Everything here is derived from posts the mesh already synced — no extra
|
||||
/// wire traffic, nothing another peer can't already see.
|
||||
///
|
||||
/// - Urgent, recent pins get one system line in the matching chat (geo pin →
|
||||
/// that geohash's timeline, mesh pin → mesh chat), collapsed when several
|
||||
/// arrive together.
|
||||
/// - Every other new pin just marks the header's pin icon until the notices
|
||||
/// sheet is opened.
|
||||
@MainActor
|
||||
final class BoardAlertsModel: ObservableObject {
|
||||
struct Dependencies {
|
||||
/// Own posts never alert; the author already knows.
|
||||
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
|
||||
/// Appends a local system line to a scope's chat timeline
|
||||
/// (geohash, or "" for mesh chat).
|
||||
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
|
||||
var now: () -> Date = Date.init
|
||||
/// Schedules the collapsed flush of pending urgent alerts; tests
|
||||
/// inject a synchronous hook.
|
||||
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts older than this at arrival are backfilled history carried in by
|
||||
/// a peer, not something happening now; they badge but never line the chat.
|
||||
static let inlineRecencyWindow: TimeInterval = 30 * 60
|
||||
/// Urgent arrivals within this window collapse into one line.
|
||||
static let collapseDelaySeconds: TimeInterval = 4
|
||||
private static let alertContentMaxChars = 120
|
||||
|
||||
/// Unseen new pins by postID (hex) → geohash scope, cleared when the
|
||||
/// notices sheet opens.
|
||||
@Published private(set) var unseenPostScopes: [String: String] = [:]
|
||||
|
||||
/// PostIDs already handled this session, so store eviction/re-sync churn
|
||||
/// can't re-alert. Bounded by session wire volume (32-byte strings).
|
||||
private var handledPostIDs = Set<String>()
|
||||
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
|
||||
private var flushScheduled = false
|
||||
private let dependencies: Dependencies
|
||||
private var cancellable: AnyCancellable?
|
||||
private var wipeCancellable: AnyCancellable?
|
||||
|
||||
private enum Strings {
|
||||
static func urgentSingle(author: String, content: String) -> String {
|
||||
String(
|
||||
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
|
||||
locale: .current,
|
||||
author, content
|
||||
)
|
||||
}
|
||||
|
||||
static func urgentCollapsed(_ count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
|
||||
locale: .current,
|
||||
count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
arrivals: AnyPublisher<BoardPostPacket, Never>,
|
||||
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
|
||||
dependencies: Dependencies
|
||||
) {
|
||||
self.dependencies = dependencies
|
||||
cancellable = arrivals
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] post in
|
||||
self?.handleArrival(post)
|
||||
}
|
||||
wipeCancellable = wipes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in
|
||||
self?.reset()
|
||||
}
|
||||
}
|
||||
|
||||
func unseenCount(forGeohash geohash: String) -> Int {
|
||||
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
|
||||
}
|
||||
|
||||
/// Marks pins in the given scopes as seen — only the scopes the notices
|
||||
/// sheet actually shows, so unseen pins for other geohash channels keep
|
||||
/// their badge until visited.
|
||||
func markSeen(forScopes scopes: Set<String>) {
|
||||
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
|
||||
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
|
||||
}
|
||||
|
||||
/// Panic wipe: drop everything derived from pre-wipe posts, including
|
||||
/// urgent lines still waiting on the collapse flush.
|
||||
func reset() {
|
||||
pendingUrgent.removeAll()
|
||||
handledPostIDs.removeAll()
|
||||
guard !unseenPostScopes.isEmpty else { return }
|
||||
unseenPostScopes.removeAll()
|
||||
}
|
||||
|
||||
func handleArrival(_ post: BoardPostPacket) {
|
||||
let postID = post.postID.hexEncodedString()
|
||||
guard !handledPostIDs.contains(postID) else { return }
|
||||
handledPostIDs.insert(postID)
|
||||
guard !dependencies.isOwnPost(post) else { return }
|
||||
|
||||
unseenPostScopes[postID] = post.geohash
|
||||
|
||||
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
||||
guard post.isUrgent,
|
||||
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
|
||||
return
|
||||
}
|
||||
pendingUrgent[post.geohash, default: []].append(post)
|
||||
if !flushScheduled {
|
||||
flushScheduled = true
|
||||
dependencies.scheduleFlush { [weak self] in
|
||||
self?.flushPendingUrgent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushPendingUrgent() {
|
||||
flushScheduled = false
|
||||
let pending = pendingUrgent
|
||||
pendingUrgent.removeAll()
|
||||
for (geohash, posts) in pending {
|
||||
guard let first = posts.first else { continue }
|
||||
let line: String
|
||||
if posts.count == 1 {
|
||||
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
|
||||
} else {
|
||||
line = Strings.urgentCollapsed(posts.count)
|
||||
}
|
||||
dependencies.emitSystemLine(line, geohash)
|
||||
}
|
||||
}
|
||||
|
||||
private static func truncated(_ content: String) -> String {
|
||||
guard content.count > alertContentMaxChars else { return content }
|
||||
return content.prefix(alertContentMaxChars) + "…"
|
||||
}
|
||||
}
|
||||
@@ -20,28 +20,17 @@ final class BoardManager: ObservableObject {
|
||||
|
||||
private let transport: Transport
|
||||
private let store: BoardStore
|
||||
/// 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 let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(
|
||||
transport: Transport,
|
||||
store: BoardStore = .shared,
|
||||
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
|
||||
deleteFromNostr: ((String, String) -> Void)? = nil
|
||||
publishToNostr: ((String, 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
|
||||
@@ -126,10 +115,10 @@ final class BoardManager: ObservableObject {
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -151,20 +140,14 @@ 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, expiresAtMs: UInt64) -> String? {
|
||||
private static func livePublishToNostr(content: String, geohash: String, nickname: 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 nil
|
||||
return
|
||||
}
|
||||
do {
|
||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||
@@ -172,26 +155,11 @@ final class BoardManager: ObservableObject {
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname,
|
||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
|
||||
nickname: nickname
|
||||
)
|
||||
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,16 +78,6 @@ 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")
|
||||
@@ -114,11 +104,6 @@ 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
|
||||
}
|
||||
@@ -164,9 +149,6 @@ final class BoardStore {
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.didWipe.send()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// UnifiedNotices.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// One row in the unified notices sheet: a mesh board post or a Nostr
|
||||
/// location note, normalized for display.
|
||||
struct NoticeItem: Identifiable, Equatable {
|
||||
enum Source: Equatable {
|
||||
/// Signed board post carried by the mesh.
|
||||
case board(BoardPostPacket)
|
||||
/// Kind-1 location note seen on geo relays.
|
||||
case nostr(LocationNotesManager.Note)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let author: String
|
||||
let content: String
|
||||
let createdAt: Date
|
||||
let isUrgent: Bool
|
||||
let source: Source
|
||||
|
||||
var isBoardPost: Bool {
|
||||
if case .board = source { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
init(post: BoardPostPacket) {
|
||||
id = post.postID.hexEncodedString()
|
||||
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
content = post.content
|
||||
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
||||
isUrgent = post.isUrgent
|
||||
source = .board(post)
|
||||
}
|
||||
|
||||
init(note: LocationNotesManager.Note) {
|
||||
id = note.id
|
||||
let display = note.displayName
|
||||
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
.first.map(String.init) ?? display
|
||||
content = note.content
|
||||
createdAt = note.createdAt
|
||||
isUrgent = false
|
||||
source = .nostr(note)
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges mesh board posts and Nostr location notes into one deduplicated
|
||||
/// list for the notices sheet's geo tab.
|
||||
enum UnifiedNotices {
|
||||
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
|
||||
/// post time, so the same notice arrives twice. The copies share content
|
||||
/// and nickname but are signed by unlinkable keys; match them
|
||||
/// heuristically by content + author within a time window.
|
||||
static let bridgeDedupeWindow: TimeInterval = 15 * 60
|
||||
|
||||
/// Returns board posts and notes as one list, urgent posts first, then
|
||||
/// newest first. Notes that look like bridged copies of a board post are
|
||||
/// dropped — the board copy wins because it carries urgency and supports
|
||||
/// merged deletion. The geohash must match exactly: the notes
|
||||
/// subscription also surfaces neighboring cells, and a same-text note
|
||||
/// from a neighbor is not the bridged copy.
|
||||
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
|
||||
var items = posts.map(NoticeItem.init(post:))
|
||||
for note in notes {
|
||||
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
|
||||
let isBridgedCopy = posts.contains { post in
|
||||
post.geohash == note.geohash
|
||||
&& post.content == note.content
|
||||
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
|
||||
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
|
||||
}
|
||||
if !isBridgedCopy {
|
||||
items.append(NoticeItem(note: note))
|
||||
}
|
||||
}
|
||||
return items.sorted {
|
||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
||||
return $0.createdAt > $1.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,7 @@ import Foundation
|
||||
/// original broadcast packet is the TTL relay's job, not ours.
|
||||
/// 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. 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).
|
||||
/// repeat deposits and relay echoes are absorbed.
|
||||
/// 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;
|
||||
@@ -156,7 +152,7 @@ final class GatewayService: ObservableObject {
|
||||
pendingDownlinks.removeAll()
|
||||
uplinkDepositTimes.removeAll()
|
||||
}
|
||||
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
|
||||
onEnabledChanged?(enabled)
|
||||
}
|
||||
|
||||
@@ -167,7 +163,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("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session)
|
||||
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
switch carrier.direction {
|
||||
@@ -190,7 +186,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.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||
@@ -201,18 +197,19 @@ final class GatewayService: ObservableObject {
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!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.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Only now pay for cryptographic verification; receivers verify again.
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,6 +219,9 @@ 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("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
|
||||
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
|
||||
}
|
||||
|
||||
/// Returns true when the item was actually stored for later publish.
|
||||
@@ -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.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway)
|
||||
return false
|
||||
}
|
||||
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 (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
|
||||
// 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
|
||||
// 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 { return }
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
|
||||
pendingDownlinks.append((event, geohash))
|
||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||
@@ -325,6 +325,7 @@ 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()
|
||||
}
|
||||
@@ -341,6 +342,7 @@ 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)
|
||||
@@ -360,9 +362,11 @@ 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 {
|
||||
@@ -417,7 +421,7 @@ final class GatewayService: ObservableObject {
|
||||
return false
|
||||
}
|
||||
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
||||
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session)
|
||||
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -15,50 +15,7 @@ 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 {
|
||||
@@ -105,7 +62,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessible as String: Self.itemAccessibility,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||
kSecAttrLabel as String: "bitchat-\(key)"
|
||||
]
|
||||
#if os(macOS)
|
||||
@@ -270,7 +227,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessible as String: Self.itemAccessibility,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||
kSecAttrLabel as String: "bitchat-\(key)"
|
||||
]
|
||||
#if os(macOS)
|
||||
|
||||
@@ -67,9 +67,6 @@ 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))
|
||||
@@ -85,8 +82,6 @@ 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?
|
||||
@@ -94,11 +89,11 @@ final class LocationNotesManager: ObservableObject {
|
||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", defaultValue: "no geo relays available near this location. try again soon.", comment: "Shown when no geo relays are available near the selected location")
|
||||
|
||||
static func failedToSend(_ detail: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
format: String(localized: "location_notes.error.failed_to_send", defaultValue: "failed to send note. %@", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
@@ -109,10 +104,10 @@ final class LocationNotesManager: ObservableObject {
|
||||
let norm = geohash.lowercased()
|
||||
self.geohash = norm
|
||||
self.dependencies = dependencies
|
||||
if !Geohash.isValidGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
if !Geohash.isValidBuildingGeohash(norm) {
|
||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 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".
|
||||
@@ -128,8 +123,9 @@ final class LocationNotesManager: ObservableObject {
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
guard Geohash.isValidGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
if let sub = subscriptionID {
|
||||
@@ -141,7 +137,6 @@ final class LocationNotesManager: ObservableObject {
|
||||
initialLoadComplete = false
|
||||
errorMessage = nil
|
||||
geohash = norm
|
||||
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
||||
notes.removeAll()
|
||||
noteIDs.removeAll()
|
||||
subscribe()
|
||||
@@ -198,14 +193,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 let matchedGeohash = event.tags.first(where: { tag in
|
||||
guard event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||
})?[1].lowercased() else { return }
|
||||
}) 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, geohash: matchedGeohash)
|
||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||
self.notes.append(note)
|
||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||
self.enforceMemoryCap()
|
||||
@@ -244,8 +239,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||
nickname: nickname,
|
||||
geohash: geohash
|
||||
nickname: nickname
|
||||
)
|
||||
self.noteIDs.insert(event.id)
|
||||
self.notes.insert(echo, at: 0)
|
||||
@@ -258,36 +252,6 @@ final class LocationNotesManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the note was published by this device's identity for the
|
||||
/// current geohash (and can therefore be deleted with NIP-09).
|
||||
func isOwnNote(_ note: Note) -> Bool {
|
||||
guard let ownPubkey else { return false }
|
||||
return note.pubkey == ownPubkey
|
||||
}
|
||||
|
||||
/// Requests NIP-09 deletion of one of our own notes and removes it locally.
|
||||
@discardableResult
|
||||
func delete(note: Note) -> Bool {
|
||||
guard isOwnNote(note) else { return false }
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
errorMessage = Strings.noRelays
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let identity = try dependencies.deriveIdentity(geohash)
|
||||
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity)
|
||||
dependencies.sendEvent(deletion, relays)
|
||||
// Keep the id in noteIDs so a relay replay can't resurrect it.
|
||||
notes.removeAll { $0.id == note.id }
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||
private func enforceMemoryCap() {
|
||||
if notes.count > maxNotesInMemory {
|
||||
|
||||
@@ -172,37 +172,17 @@ 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,
|
||||
nostrEventStore: NostrProcessedEventStore? = nil
|
||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
||||
) {
|
||||
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
|
||||
@@ -259,26 +239,6 @@ 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
|
||||
@@ -303,20 +263,14 @@ final class MessageDeduplicationService {
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||
/// gift-wrap record goes with everything else.
|
||||
/// Clears all caches
|
||||
func clearAll() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
pendingPersistIDs.removeAll()
|
||||
nostrEventStore?.wipe()
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Clears only the Nostr caches (events and ACKs)
|
||||
func clearNostrCaches() {
|
||||
nostrEventCache.clear()
|
||||
nostrAckCache.clear()
|
||||
|
||||
@@ -25,20 +25,14 @@ extension NostrRelayManager: NetworkActivationRelayControlling {}
|
||||
extension TorURLSession: NetworkActivationProxyControlling {}
|
||||
|
||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||
/// 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.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||
@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
|
||||
@@ -49,7 +43,6 @@ 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
|
||||
@@ -65,7 +58,6 @@ 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
|
||||
@@ -78,7 +70,6 @@ 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,
|
||||
@@ -89,7 +80,6 @@ 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
|
||||
@@ -106,12 +96,8 @@ final class NetworkActivationService: ObservableObject {
|
||||
userTorEnabled = true
|
||||
}
|
||||
|
||||
// Begin (idempotent) reachability monitoring and seed initial state.
|
||||
reachabilityMonitor.start()
|
||||
isNetworkReachable = reachabilityMonitor.isReachable
|
||||
|
||||
// Initial compute
|
||||
let allowed = effectiveAllowed()
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||
@@ -137,21 +123,6 @@ 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) {
|
||||
@@ -167,7 +138,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
|
||||
private func reevaluate() {
|
||||
let allowed = effectiveAllowed()
|
||||
let allowed = basePolicyAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
let torChanged = torDesired != torAutoStartDesired
|
||||
@@ -192,20 +163,12 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Base policy: who is allowed to use the network at all (permission or a
|
||||
/// mutual favorite), ignoring current link state.
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
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 {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
#endif
|
||||
|
||||
/// Coarse, conservative network-reachability signal used to gate Tor bootstrap
|
||||
/// and Nostr relay connections.
|
||||
///
|
||||
/// Policy (deliberately conservative):
|
||||
/// - Reports `false` only when the OS says there is *no* usable interface at
|
||||
/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays
|
||||
/// `true` because Tor tolerates intermittent connectivity, and tearing down
|
||||
/// on the first hiccup would cost more battery/latency than it saves.
|
||||
/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping
|
||||
/// does not thrash Tor/relay startup.
|
||||
/// - Starts optimistic (`true`) so nothing is ever suppressed before the first
|
||||
/// path evaluation arrives.
|
||||
///
|
||||
/// BLE mesh must never consult this monitor — the mesh works fully offline.
|
||||
@MainActor
|
||||
protocol NetworkReachabilityMonitoring: AnyObject {
|
||||
/// Current debounced coarse reachability.
|
||||
var isReachable: Bool { get }
|
||||
/// Emits the debounced reachability whenever it changes (main-actor).
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||
/// Begin monitoring. Idempotent.
|
||||
func start()
|
||||
}
|
||||
|
||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
||||
/// unit-tested without the Network framework or real timers.
|
||||
///
|
||||
/// A candidate state only becomes the committed state once it has been stable
|
||||
/// (uninterrupted) for `interval`. Any observation matching the committed state
|
||||
/// cancels a pending opposite change, which is what makes flapping a no-op.
|
||||
struct ReachabilityDebounce {
|
||||
let interval: TimeInterval
|
||||
private(set) var committed: Bool
|
||||
private var pending: (value: Bool, since: Date)?
|
||||
|
||||
init(interval: TimeInterval, initial: Bool) {
|
||||
self.interval = interval
|
||||
self.committed = initial
|
||||
}
|
||||
|
||||
/// Whether a change is currently waiting out the debounce window.
|
||||
var hasPendingChange: Bool { pending != nil }
|
||||
|
||||
/// Time left before the pending change may commit, or `nil` when nothing
|
||||
/// is pending. Lets callers schedule a flush at the true deadline instead
|
||||
/// of a full interval from "now" (duplicate observations must not push
|
||||
/// the deadline out).
|
||||
func pendingRemaining(at now: Date) -> TimeInterval? {
|
||||
guard let pending else { return nil }
|
||||
return max(0, interval - now.timeIntervalSince(pending.since))
|
||||
}
|
||||
|
||||
/// Feed a raw observation. Returns the new committed value if it changed,
|
||||
/// otherwise `nil`.
|
||||
mutating func observe(reachable: Bool, at now: Date) -> Bool? {
|
||||
if reachable == committed {
|
||||
// Already in this state — cancel any pending opposite change.
|
||||
pending = nil
|
||||
return nil
|
||||
}
|
||||
// Differs from committed: (re)arm the pending change, preserving the
|
||||
// timestamp if we're already waiting on this same target value.
|
||||
if pending?.value != reachable {
|
||||
pending = (reachable, now)
|
||||
}
|
||||
return commitIfAged(at: now)
|
||||
}
|
||||
|
||||
/// Called from a timer to commit a pending change once it has aged past
|
||||
/// `interval`. Returns the new committed value if it changed, else `nil`.
|
||||
mutating func flush(at now: Date) -> Bool? {
|
||||
commitIfAged(at: now)
|
||||
}
|
||||
|
||||
private mutating func commitIfAged(at now: Date) -> Bool? {
|
||||
guard let pending else { return nil }
|
||||
guard now.timeIntervalSince(pending.since) >= interval else { return nil }
|
||||
committed = pending.value
|
||||
self.pending = nil
|
||||
return committed
|
||||
}
|
||||
}
|
||||
|
||||
/// Always-reachable stub. Used as the default in tests and as the fallback on
|
||||
/// platforms without the Network framework, so reachability never suppresses
|
||||
/// startup by itself.
|
||||
@MainActor
|
||||
final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
||||
var isReachable: Bool { true }
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
func start() {}
|
||||
}
|
||||
|
||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||
/// background path callback hops here before touching the debounce.
|
||||
@MainActor
|
||||
final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
||||
private let subject: CurrentValueSubject<Bool, Never>
|
||||
private var debounce: ReachabilityDebounce
|
||||
private var flushWorkItem: DispatchWorkItem?
|
||||
private var started = false
|
||||
private let now: () -> Date
|
||||
|
||||
#if canImport(Network)
|
||||
private var monitor: NWPathMonitor?
|
||||
private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability")
|
||||
#endif
|
||||
|
||||
init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true)
|
||||
self.subject = CurrentValueSubject(true)
|
||||
}
|
||||
|
||||
var isReachable: Bool { subject.value }
|
||||
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
#if canImport(Network)
|
||||
let monitor = NWPathMonitor()
|
||||
self.monitor = monitor
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
// Conservative: only "no interface at all" counts as unreachable.
|
||||
let reachable = path.status != .unsatisfied
|
||||
Task { @MainActor in
|
||||
self?.ingest(reachable: reachable)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: monitorQueue)
|
||||
#else
|
||||
// No Network framework: never suppress startup.
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Feed an observation into the debounce and publish committed changes.
|
||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||
func ingest(reachable: Bool) {
|
||||
flushWorkItem?.cancel()
|
||||
flushWorkItem = nil
|
||||
if let committed = debounce.observe(reachable: reachable, at: now()) {
|
||||
publish(committed)
|
||||
} else if debounce.hasPendingChange {
|
||||
scheduleFlush()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFlush() {
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
if let committed = self.debounce.flush(at: self.now()) {
|
||||
self.publish(committed)
|
||||
}
|
||||
}
|
||||
flushWorkItem = work
|
||||
// Fire at the pending change's real deadline (pending.since + interval):
|
||||
// duplicate path updates re-enter here and must not restart the window.
|
||||
let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
||||
}
|
||||
|
||||
private func publish(_ reachable: Bool) {
|
||||
SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session)
|
||||
subject.send(reachable)
|
||||
}
|
||||
}
|
||||
@@ -116,30 +116,30 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||
return String(localized: "encryption.status.failed", defaultValue: "encryption failed", comment: "Status text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||
return String(localized: "encryption.status.not_encrypted", defaultValue: "not encrypted", comment: "Status text when no encryption handshake happened")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||
return String(localized: "encryption.status.establishing", defaultValue: "establishing encryption...", comment: "Status text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||
return String(localized: "encryption.status.secured", defaultValue: "encrypted", comment: "Status text when encryption is secured but not verified")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||
return String(localized: "encryption.status.verified", defaultValue: "encrypted & verified", comment: "Status text when encryption is verified")
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||
return String(localized: "encryption.accessibility.failed", defaultValue: "encryption failed", comment: "Accessibility text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||
return String(localized: "encryption.accessibility.not_encrypted", defaultValue: "not encrypted", comment: "Accessibility text when encryption is not established")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||
return String(localized: "encryption.accessibility.establishing", defaultValue: "establishing encryption", comment: "Accessibility text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||
return String(localized: "encryption.accessibility.secured", defaultValue: "encrypted", comment: "Accessibility text when encryption is secured")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||
return String(localized: "encryption.accessibility.verified", defaultValue: "encrypted and verified", comment: "Accessibility text when encryption is verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,6 +463,7 @@ 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(
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
//
|
||||
// NostrProcessedEventStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
||||
/// and relays redeliver the same events on every launch — without a
|
||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||
///
|
||||
/// Contents are event IDs already visible to every relay, so
|
||||
/// until-first-unlock file protection is the right at-rest posture — the
|
||||
/// file must also load during a locked-background restoration relaunch.
|
||||
/// Wiped on panic via the dedup service's clear paths.
|
||||
final class NostrProcessedEventStore {
|
||||
private let fileURL: URL?
|
||||
// All file access is serialized here: appends are read-modify-write, and
|
||||
// overlapping debounced flushes would otherwise race and drop IDs.
|
||||
private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility)
|
||||
|
||||
init(fileURL: URL? = nil) {
|
||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||
}
|
||||
|
||||
/// Processed event IDs, oldest first (insertion order).
|
||||
func load() -> [String] {
|
||||
ioQueue.sync { loadLocked() }
|
||||
}
|
||||
|
||||
/// Merge new IDs onto the persisted record, oldest-first, trimming from
|
||||
/// the front past `cap`. Append-merge (not snapshot-overwrite) so the
|
||||
/// in-memory cache being cleared transiently (channel switches) can
|
||||
/// never shrink the on-disk record.
|
||||
func append(_ newIDs: [String], cap: Int) {
|
||||
guard !newIDs.isEmpty else { return }
|
||||
ioQueue.async { [self] in
|
||||
var merged = loadLocked()
|
||||
var known = Set(merged)
|
||||
for id in newIDs where !known.contains(id) {
|
||||
merged.append(id)
|
||||
known.insert(id)
|
||||
}
|
||||
if merged.count > cap {
|
||||
merged.removeFirst(merged.count - cap)
|
||||
}
|
||||
saveLocked(merged)
|
||||
}
|
||||
}
|
||||
|
||||
func wipe() {
|
||||
ioQueue.async { [self] in
|
||||
guard let fileURL else { return }
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLocked() -> [String] {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let ids = try? JSONDecoder().decode([String].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
private func saveLocked(_ eventIDs: [String]) {
|
||||
guard let fileURL else { return }
|
||||
guard !eventIDs.isEmpty else {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(eventIDs)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("nostr", isDirectory: true)
|
||||
.appendingPathComponent("processed-events.json")
|
||||
}
|
||||
}
|
||||
@@ -19,36 +19,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// 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 {
|
||||
@@ -63,8 +33,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
||||
ackPacer: NostrTransport.sharedAckPacer
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,64 +41,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
// 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)
|
||||
// Throttle READ receipts to avoid relay rate limits
|
||||
private struct QueuedRead {
|
||||
let receipt: ReadReceipt
|
||||
let peerID: PeerID
|
||||
}
|
||||
|
||||
/// 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 var readQueue: [QueuedRead] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let dependencies: Dependencies
|
||||
@@ -268,14 +187,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
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) }
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
@@ -295,7 +212,17 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
enqueueAck(.deliveredDirect(messageID: messageID, peerID: peerID))
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,11 +232,19 @@ extension NostrTransport {
|
||||
|
||||
// MARK: Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
enqueueAck(.deliveredGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
enqueueAck(.readGeohash(messageID: messageID, recipientHex: recipientHex, identity: identity))
|
||||
Task { @MainActor in
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Geohash DMs (per-geohash identity)
|
||||
@@ -355,42 +290,36 @@ 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 ack item (invoked by the pacer, one per interval)
|
||||
private func sendAckItem(_ item: QueuedAck) {
|
||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
||||
private func sendReadAckItem(_ item: QueuedRead) {
|
||||
Task { @MainActor in
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private func scheduleNextReadAck() {
|
||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||
self?.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.isSendingReadAcks = false
|
||||
self?.processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,10 +167,6 @@ 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
|
||||
@@ -182,6 +178,10 @@ 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,14 +223,13 @@ extension Transport {
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func 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 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.
|
||||
@@ -239,6 +238,7 @@ 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,16 +242,6 @@ 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
|
||||
@@ -315,6 +305,21 @@ 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
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//
|
||||
// WifiBulkChannel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
|
||||
/// the connection's dispatch queue.
|
||||
enum WifiBulkStream {
|
||||
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
|
||||
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
|
||||
chunkBytes + WifiBulkCrypto.frameOverhead
|
||||
}
|
||||
|
||||
/// Reads frames until `onFrame` returns false (stop) or the stream
|
||||
/// errors/closes. `onFrame` returning true keeps the loop alive.
|
||||
static func readFrames(
|
||||
on connection: NWConnection,
|
||||
buffer: WifiBulkFrameBuffer,
|
||||
maxFrameBodyBytes: Int,
|
||||
onFrame: @escaping (Data) -> Bool,
|
||||
onError: @escaping (String) -> Void
|
||||
) {
|
||||
// Drain any frames already buffered before touching the socket.
|
||||
do {
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
guard onFrame(body) else { return }
|
||||
}
|
||||
} catch {
|
||||
onError("frame decode failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
connection.receive(
|
||||
minimumIncompleteLength: 1,
|
||||
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
|
||||
) { data, _, isComplete, error in
|
||||
if let data, !data.isEmpty {
|
||||
buffer.append(data)
|
||||
}
|
||||
if let error {
|
||||
onError("receive failed: \(error)")
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// Peer closed: hand over whatever complete frames remain, then
|
||||
// report the close (sessions that already got what they need
|
||||
// will have stopped the loop from inside onFrame).
|
||||
do {
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
guard onFrame(body) else { return }
|
||||
}
|
||||
} catch {
|
||||
onError("frame decode failed: \(error)")
|
||||
return
|
||||
}
|
||||
onError("connection closed by peer")
|
||||
return
|
||||
}
|
||||
readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: maxFrameBodyBytes,
|
||||
onFrame: onFrame,
|
||||
onError: onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
|
||||
/// listener, requires the first inbound frame to prove knowledge of the
|
||||
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
|
||||
/// receiver's verified receipt.
|
||||
///
|
||||
/// The listener starts at offer time (Bonjour registration takes a moment)
|
||||
/// but data can only flow after `activate(key:)` supplies the channel key
|
||||
/// derived from the accepted response.
|
||||
final class WifiBulkSenderSession {
|
||||
private let queue: DispatchQueue
|
||||
private let payload: Data
|
||||
private let transferID: Data
|
||||
private let payloadHash: Data
|
||||
private let chunkBytes: Int
|
||||
private let parameters: NWParameters
|
||||
private let service: NWListener.Service?
|
||||
private let maxCandidateConnections = 4
|
||||
|
||||
private var key: SymmetricKey?
|
||||
private var listener: NWListener?
|
||||
/// Connections that have not yet produced a valid auth frame.
|
||||
private var candidates: [NWConnection] = []
|
||||
private var authenticated: NWConnection?
|
||||
private var finished = false
|
||||
|
||||
let totalChunks: Int
|
||||
|
||||
/// Test hook: fires once the listener is ready, with its bound port.
|
||||
var onListenerReady: ((UInt16) -> Void)?
|
||||
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
|
||||
var onCompleted: (() -> Void)?
|
||||
var onFailed: ((String) -> Void)?
|
||||
|
||||
init(
|
||||
payload: Data,
|
||||
transferID: Data,
|
||||
chunkBytes: Int,
|
||||
parameters: NWParameters,
|
||||
service: NWListener.Service?,
|
||||
queue: DispatchQueue
|
||||
) {
|
||||
self.payload = payload
|
||||
self.transferID = transferID
|
||||
self.payloadHash = Data(SHA256.hash(data: payload))
|
||||
self.chunkBytes = chunkBytes
|
||||
self.parameters = parameters
|
||||
self.service = service
|
||||
self.queue = queue
|
||||
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelNetworkResources()
|
||||
}
|
||||
|
||||
/// Starts the listener. Returns false when the listener cannot be created
|
||||
/// (caller falls back to BLE immediately).
|
||||
func start() -> Bool {
|
||||
let listener: NWListener
|
||||
do {
|
||||
listener = try NWListener(using: parameters)
|
||||
} catch {
|
||||
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
|
||||
return false
|
||||
}
|
||||
listener.service = service
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = listener.port?.rawValue {
|
||||
self.onListenerReady?(port)
|
||||
}
|
||||
case .failed(let error):
|
||||
self.fail("listener failed: \(error)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.acceptCandidate(connection)
|
||||
}
|
||||
self.listener = listener
|
||||
listener.start(queue: queue)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Supplies the channel key once the receiver accepted the offer; begins
|
||||
/// authenticating any connections that raced ahead of the response.
|
||||
func activate(key: SymmetricKey) {
|
||||
guard !finished, self.key == nil else { return }
|
||||
self.key = key
|
||||
for candidate in candidates {
|
||||
beginAuthentication(on: candidate, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finished = true
|
||||
cancelNetworkResources()
|
||||
}
|
||||
|
||||
// MARK: - Connection handling
|
||||
|
||||
private func acceptCandidate(_ connection: NWConnection) {
|
||||
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
candidates.append(connection)
|
||||
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||
guard let self, let connection else { return }
|
||||
if case .failed = state {
|
||||
self.dropCandidate(connection)
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
if let key {
|
||||
beginAuthentication(on: connection, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func dropCandidate(_ connection: NWConnection) {
|
||||
if let index = candidates.firstIndex(where: { $0 === connection }) {
|
||||
candidates.remove(at: index)
|
||||
connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self, weak connection] body in
|
||||
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
|
||||
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
|
||||
// Bonjour-level gatecrasher: no channel key, no service.
|
||||
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
|
||||
self.dropCandidate(connection)
|
||||
return false
|
||||
}
|
||||
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
|
||||
return false
|
||||
},
|
||||
onError: { [weak self, weak connection] _ in
|
||||
guard let self, let connection, self.authenticated !== connection else { return }
|
||||
self.dropCandidate(connection)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
|
||||
authenticated = connection
|
||||
// One authenticated peer is all a transfer needs: stop advertising and
|
||||
// shed the other candidates.
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
for candidate in candidates where candidate !== connection {
|
||||
candidate.cancel()
|
||||
}
|
||||
candidates.removeAll()
|
||||
SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport)
|
||||
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
|
||||
guard !finished else { return }
|
||||
guard index < totalChunks else {
|
||||
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
|
||||
return
|
||||
}
|
||||
|
||||
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
|
||||
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
|
||||
let chunk = Data(payload[start..<end])
|
||||
|
||||
let body: Data
|
||||
do {
|
||||
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
|
||||
} catch {
|
||||
fail("chunk seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error {
|
||||
self.fail("send failed: \(error)")
|
||||
return
|
||||
}
|
||||
self.onChunkSent?(index + 1, self.totalChunks)
|
||||
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
|
||||
})
|
||||
}
|
||||
|
||||
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self] body in
|
||||
guard let self, !self.finished else { return false }
|
||||
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
|
||||
self.fail("invalid receipt frame")
|
||||
return false
|
||||
}
|
||||
self.finished = true
|
||||
self.cancelNetworkResources()
|
||||
self.onCompleted?()
|
||||
return false
|
||||
},
|
||||
onError: { [weak self] reason in
|
||||
self?.fail("receipt wait failed: \(reason)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Teardown
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
cancelNetworkResources()
|
||||
onFailed?(reason)
|
||||
}
|
||||
|
||||
private func cancelNetworkResources() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
authenticated?.cancel()
|
||||
authenticated = nil
|
||||
for candidate in candidates {
|
||||
candidate.cancel()
|
||||
}
|
||||
candidates.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver side of the bulk channel: connects to the sender's per-transfer
|
||||
/// endpoint, proves knowledge of the channel key with the first frame, then
|
||||
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
|
||||
final class WifiBulkReceiverSession {
|
||||
private let queue: DispatchQueue
|
||||
private let connection: NWConnection
|
||||
private let key: SymmetricKey
|
||||
private let transferID: Data
|
||||
private let payloadHash: Data
|
||||
private let chunkBytes: Int
|
||||
private let assembler: WifiBulkPayloadAssembler
|
||||
|
||||
private var finished = false
|
||||
|
||||
var onCompleted: ((Data) -> Void)?
|
||||
var onFailed: ((String) -> Void)?
|
||||
|
||||
/// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver
|
||||
/// enforces the cap it advertised, not the sender's word.
|
||||
init?(
|
||||
endpoint: NWEndpoint,
|
||||
parameters: NWParameters,
|
||||
key: SymmetricKey,
|
||||
transferID: Data,
|
||||
expectedSize: UInt64,
|
||||
expectedHash: Data,
|
||||
sizeCap: Int,
|
||||
chunkBytes: Int,
|
||||
queue: DispatchQueue
|
||||
) {
|
||||
guard let assembler = WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: expectedSize,
|
||||
expectedHash: expectedHash,
|
||||
sizeCap: sizeCap
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
self.assembler = assembler
|
||||
self.connection = NWConnection(to: endpoint, using: parameters)
|
||||
self.key = key
|
||||
self.transferID = transferID
|
||||
self.payloadHash = expectedHash
|
||||
self.chunkBytes = chunkBytes
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
deinit {
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
func start() {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport)
|
||||
self.sendAuthFrameAndReceive()
|
||||
case .failed(let error):
|
||||
SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport)
|
||||
self.fail("connect failed: \(error)")
|
||||
case .waiting(let error):
|
||||
// .waiting can resolve on its own, but a per-transfer channel
|
||||
// has a peer actively listening; treat unreachable as fatal so
|
||||
// the sender's fallback isn't left to the window timeout alone.
|
||||
self.fail("connection waiting: \(error)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finished = true
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
private func sendAuthFrameAndReceive() {
|
||||
guard !finished else { return }
|
||||
let authBody: Data
|
||||
do {
|
||||
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||
} catch {
|
||||
fail("auth frame seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error {
|
||||
self.fail("auth frame send failed: \(error)")
|
||||
return
|
||||
}
|
||||
self.receiveChunks()
|
||||
})
|
||||
}
|
||||
|
||||
private func receiveChunks() {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self] body in
|
||||
guard let self, !self.finished else { return false }
|
||||
do {
|
||||
guard let payload = try self.assembler.consume(frameBody: body) else {
|
||||
return true // keep reading
|
||||
}
|
||||
self.sendReceiptAndComplete(payload)
|
||||
return false
|
||||
} catch {
|
||||
self.fail("chunk rejected: \(error)")
|
||||
return false
|
||||
}
|
||||
},
|
||||
onError: { [weak self] reason in
|
||||
self?.fail(reason)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func sendReceiptAndComplete(_ payload: Data) {
|
||||
let receiptBody: Data
|
||||
do {
|
||||
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
|
||||
} catch {
|
||||
fail("receipt seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
|
||||
// Receipt is best-effort from the receiver's perspective: the
|
||||
// payload is already verified. Close the channel either way.
|
||||
guard let self, !self.finished else { return }
|
||||
self.finished = true
|
||||
self.connection.cancel()
|
||||
self.onCompleted?(payload)
|
||||
})
|
||||
}
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
connection.cancel()
|
||||
onFailed?(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// WifiBulkCrypto.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Channel security for the Wi-Fi bulk data plane.
|
||||
///
|
||||
/// The TCP stream is encrypted and authenticated independently of TLS: both
|
||||
/// endpoints exchanged random 32-byte tokens inside the established Noise
|
||||
/// session, so only they can derive the ChaChaPoly channel key via
|
||||
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
|
||||
/// gatecrasher that connects to the listener cannot produce a single valid
|
||||
/// frame and is disconnected.
|
||||
///
|
||||
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
|
||||
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
|
||||
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
|
||||
/// and the reader requires the exact expected nonce for each frame, so frames
|
||||
/// cannot be replayed, reordered, or reflected across directions.
|
||||
enum WifiBulkCryptoError: Error, Equatable {
|
||||
case invalidParameters
|
||||
case frameTooLarge
|
||||
case truncatedFrame
|
||||
case nonceMismatch
|
||||
case authenticationFailed
|
||||
case emptyChunk
|
||||
case payloadOverflow
|
||||
case hashMismatch
|
||||
}
|
||||
|
||||
enum WifiBulkFrameDirection: UInt8 {
|
||||
/// Data chunks: counters 0, 1, 2, …
|
||||
case senderToReceiver = 0x00
|
||||
/// Counter 0 = client auth frame, counter 1 = final receipt.
|
||||
case receiverToSender = 0x01
|
||||
}
|
||||
|
||||
enum WifiBulkCrypto {
|
||||
static let keyDomain = "bitchat-bulk-v1"
|
||||
static let nonceLength = 12
|
||||
static let tagLength = 16
|
||||
/// AEAD overhead per frame body (nonce + tag).
|
||||
static let frameOverhead = nonceLength + tagLength
|
||||
/// 4-byte big-endian length prefix per frame.
|
||||
static let framePrefixLength = 4
|
||||
|
||||
// MARK: Key derivation
|
||||
|
||||
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
|
||||
/// Deterministic: same tokens + transferID always yield the same key.
|
||||
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
|
||||
guard senderToken.count == WifiBulkWire.tokenLength,
|
||||
receiverToken.count == WifiBulkWire.tokenLength,
|
||||
transferID.count == WifiBulkWire.transferIDLength else {
|
||||
return nil
|
||||
}
|
||||
var inputKeyMaterial = Data()
|
||||
inputKeyMaterial.append(senderToken)
|
||||
inputKeyMaterial.append(receiverToken)
|
||||
return HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
|
||||
salt: transferID,
|
||||
info: Data(keyDomain.utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Frame sealing
|
||||
|
||||
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
|
||||
var nonce = Data(count: nonceLength)
|
||||
nonce[0] = direction.rawValue
|
||||
var counterBE = counter.bigEndian
|
||||
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
|
||||
return nonce
|
||||
}
|
||||
|
||||
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
|
||||
static func sealFrameBody(
|
||||
_ plaintext: Data,
|
||||
direction: WifiBulkFrameDirection,
|
||||
counter: UInt64,
|
||||
key: SymmetricKey
|
||||
) throws -> Data {
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
|
||||
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
|
||||
}
|
||||
|
||||
/// Opens one frame body, enforcing the exact expected nonce.
|
||||
static func openFrameBody(
|
||||
_ body: Data,
|
||||
direction: WifiBulkFrameDirection,
|
||||
counter: UInt64,
|
||||
key: SymmetricKey
|
||||
) throws -> Data {
|
||||
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
|
||||
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
|
||||
throw WifiBulkCryptoError.nonceMismatch
|
||||
}
|
||||
do {
|
||||
let box = try ChaChaPoly.SealedBox(combined: body)
|
||||
return try ChaChaPoly.open(box, using: key)
|
||||
} catch {
|
||||
throw WifiBulkCryptoError.authenticationFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
|
||||
static func frameData(body: Data) -> Data {
|
||||
var framed = Data(capacity: framePrefixLength + body.count)
|
||||
var lengthBE = UInt32(body.count).bigEndian
|
||||
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
|
||||
framed.append(body)
|
||||
return framed
|
||||
}
|
||||
|
||||
// MARK: Control frames
|
||||
|
||||
/// First frame on the wire, receiver → sender: proves the connecting
|
||||
/// client holds the Noise-exchanged secret before any data flows.
|
||||
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
|
||||
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
|
||||
}
|
||||
|
||||
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
|
||||
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
|
||||
}
|
||||
|
||||
/// Final frame, receiver → sender: acknowledges the fully verified payload.
|
||||
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
|
||||
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
|
||||
}
|
||||
|
||||
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
|
||||
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
|
||||
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
|
||||
final class WifiBulkFrameBuffer {
|
||||
private var buffer = Data()
|
||||
private let maxBodyBytes: Int
|
||||
|
||||
init(maxBodyBytes: Int) {
|
||||
self.maxBodyBytes = maxBodyBytes
|
||||
}
|
||||
|
||||
func append(_ data: Data) {
|
||||
buffer.append(data)
|
||||
}
|
||||
|
||||
/// Extracts the next complete frame body, or nil when more bytes are needed.
|
||||
func nextFrameBody() throws -> Data? {
|
||||
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
|
||||
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
|
||||
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
|
||||
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
|
||||
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
|
||||
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver-side reassembly: opens sequential data frames, enforces the size
|
||||
/// negotiated in the accepted offer, and verifies the final SHA-256.
|
||||
final class WifiBulkPayloadAssembler {
|
||||
private let key: SymmetricKey
|
||||
private let expectedSize: Int
|
||||
private let expectedHash: Data
|
||||
private var received = Data()
|
||||
private var counter: UInt64 = 0
|
||||
|
||||
/// Fails when the offer exceeds the receiver-enforced cap.
|
||||
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
|
||||
guard expectedSize > 0,
|
||||
expectedSize <= UInt64(sizeCap),
|
||||
expectedHash.count == WifiBulkWire.hashLength else {
|
||||
return nil
|
||||
}
|
||||
self.key = key
|
||||
self.expectedSize = Int(expectedSize)
|
||||
self.expectedHash = expectedHash
|
||||
}
|
||||
|
||||
var isComplete: Bool { received.count == expectedSize }
|
||||
|
||||
/// Consumes one sealed data frame body. Returns the verified payload when
|
||||
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
|
||||
func consume(frameBody: Data) throws -> Data? {
|
||||
let chunk = try WifiBulkCrypto.openFrameBody(
|
||||
frameBody,
|
||||
direction: .senderToReceiver,
|
||||
counter: counter,
|
||||
key: key
|
||||
)
|
||||
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
|
||||
counter += 1
|
||||
guard received.count + chunk.count <= expectedSize else {
|
||||
throw WifiBulkCryptoError.payloadOverflow
|
||||
}
|
||||
received.append(chunk)
|
||||
guard isComplete else { return nil }
|
||||
guard Data(SHA256.hash(data: received)) == expectedHash else {
|
||||
throw WifiBulkCryptoError.hashMismatch
|
||||
}
|
||||
return received
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// WifiBulkMessages.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
|
||||
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
|
||||
///
|
||||
/// Both messages ride the encrypted Noise channel, so every field — including
|
||||
/// the session tokens and the random Bonjour instance name — is only visible
|
||||
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
|
||||
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
|
||||
/// compatibility.
|
||||
enum WifiBulkWire {
|
||||
static let transferIDLength = 16
|
||||
static let tokenLength = 32
|
||||
static let hashLength = 32
|
||||
/// Bonjour instance names are capped at 63 UTF-8 bytes.
|
||||
static let maxServiceNameBytes = 63
|
||||
|
||||
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
|
||||
data.append(type)
|
||||
var length = UInt16(value.count).bigEndian
|
||||
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
|
||||
data.append(value)
|
||||
}
|
||||
|
||||
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
|
||||
/// Returns false when the buffer is structurally malformed.
|
||||
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
while cursor < end {
|
||||
let type = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
guard data.distance(from: cursor, to: end) >= 2 else { return false }
|
||||
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||
cursor = data.index(cursor, offsetBy: 2)
|
||||
guard data.distance(from: cursor, to: end) >= length else { return false }
|
||||
let valueEnd = data.index(cursor, offsetBy: length)
|
||||
visit(type, Data(data[cursor..<valueEnd]))
|
||||
cursor = valueEnd
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender → receiver: proposal to move an already-encoded file payload over
|
||||
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
|
||||
struct WifiBulkOffer: Equatable {
|
||||
/// Random per-transfer identifier; also the HKDF salt.
|
||||
let transferID: Data
|
||||
/// Exact byte count of the payload that will cross the channel.
|
||||
let fileSize: UInt64
|
||||
/// SHA-256 over the payload bytes as they cross the channel, verified by
|
||||
/// the receiver after reassembly.
|
||||
let payloadHash: Data
|
||||
/// Sender's random half of the channel secret.
|
||||
let token: Data
|
||||
/// Random Bonjour instance name the sender publishes for this transfer.
|
||||
/// Never derived from nickname or peer ID.
|
||||
let serviceName: String
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case transferID = 0x01
|
||||
case fileSize = 0x02
|
||||
case payloadHash = 0x03
|
||||
case token = 0x04
|
||||
case serviceName = 0x05
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard transferID.count == WifiBulkWire.transferIDLength,
|
||||
payloadHash.count == WifiBulkWire.hashLength,
|
||||
token.count == WifiBulkWire.tokenLength else { return nil }
|
||||
let nameData = Data(serviceName.utf8)
|
||||
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
|
||||
|
||||
var encoded = Data()
|
||||
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||
var sizeBE = fileSize.bigEndian
|
||||
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WifiBulkOffer? {
|
||||
var transferID: Data?
|
||||
var fileSize: UInt64?
|
||||
var payloadHash: Data?
|
||||
var token: Data?
|
||||
var serviceName: String?
|
||||
|
||||
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||
switch TLVType(rawValue: type) {
|
||||
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||
transferID = value
|
||||
case .fileSize where value.count == 8:
|
||||
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .payloadHash where value.count == WifiBulkWire.hashLength:
|
||||
payloadHash = value
|
||||
case .token where value.count == WifiBulkWire.tokenLength:
|
||||
token = value
|
||||
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
|
||||
serviceName = String(data: value, encoding: .utf8)
|
||||
default:
|
||||
break // Unknown or malformed field: ignore; required checks below.
|
||||
}
|
||||
}
|
||||
guard wellFormed,
|
||||
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
|
||||
return nil
|
||||
}
|
||||
return WifiBulkOffer(
|
||||
transferID: transferID,
|
||||
fileSize: fileSize,
|
||||
payloadHash: payloadHash,
|
||||
token: token,
|
||||
serviceName: serviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver → sender: accept (with the receiver's token half) or decline.
|
||||
struct WifiBulkResponse: Equatable {
|
||||
let transferID: Data
|
||||
let accepted: Bool
|
||||
/// Receiver's random half of the channel secret; present iff accepted.
|
||||
let token: Data?
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case transferID = 0x01
|
||||
case accepted = 0x02
|
||||
case token = 0x03
|
||||
}
|
||||
|
||||
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
|
||||
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||
}
|
||||
|
||||
static func decline(transferID: Data) -> WifiBulkResponse {
|
||||
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
|
||||
if accepted {
|
||||
guard token?.count == WifiBulkWire.tokenLength else { return nil }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
|
||||
if accepted, let token {
|
||||
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WifiBulkResponse? {
|
||||
var transferID: Data?
|
||||
var accepted: Bool?
|
||||
var token: Data?
|
||||
|
||||
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||
switch TLVType(rawValue: type) {
|
||||
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||
transferID = value
|
||||
case .accepted where value.count == 1:
|
||||
accepted = value.first == 1
|
||||
case .token where value.count == WifiBulkWire.tokenLength:
|
||||
token = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard wellFormed, let transferID, let accepted else { return nil }
|
||||
if accepted {
|
||||
guard let token else { return nil }
|
||||
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||
}
|
||||
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// WifiBulkPolicy.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
|
||||
/// fails these gates rides BLE fragmentation exactly as before — the BLE
|
||||
/// fallback is the common case and must stay bulletproof.
|
||||
enum WifiBulkPolicy {
|
||||
struct SendCandidate {
|
||||
let payloadBytes: Int
|
||||
let peerCapabilities: PeerCapabilities
|
||||
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
|
||||
/// only reaches direct neighbors, and relays can't proxy the channel.
|
||||
let isDirectlyConnected: Bool
|
||||
/// The offer rides the Noise session, so one must already exist.
|
||||
let hasEstablishedNoiseSession: Bool
|
||||
}
|
||||
|
||||
static func shouldOffer(
|
||||
_ candidate: SendCandidate,
|
||||
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
|
||||
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
) -> Bool {
|
||||
enabled
|
||||
&& candidate.payloadBytes > minPayloadBytes
|
||||
&& candidate.payloadBytes <= maxPayloadBytes
|
||||
&& candidate.peerCapabilities.contains(.wifiBulk)
|
||||
&& candidate.isDirectlyConnected
|
||||
&& candidate.hasEstablishedNoiseSession
|
||||
}
|
||||
|
||||
/// Receiver-side gate. Field lengths were validated at decode; this
|
||||
/// enforces the size cap (from the local ceiling, not the sender's word)
|
||||
/// and local enablement.
|
||||
static func shouldAccept(
|
||||
offer: WifiBulkOffer,
|
||||
senderIsDirectlyConnected: Bool,
|
||||
activeIncomingTransfers: Int,
|
||||
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
|
||||
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||
) -> Bool {
|
||||
enabled
|
||||
&& senderIsDirectlyConnected
|
||||
&& activeIncomingTransfers < maxConcurrentIncoming
|
||||
&& offer.fileSize > 0
|
||||
&& offer.fileSize <= UInt64(maxPayloadBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
//
|
||||
// WifiBulkTransferService.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
|
||||
/// hops live inside the closures supplied by `BLEService`, keeping this
|
||||
/// service independently testable.
|
||||
struct WifiBulkTransferServiceEnvironment {
|
||||
/// Sends a typed payload inside the established Noise session with the
|
||||
/// peer. Returns false when no established session exists (the caller
|
||||
/// falls back to BLE).
|
||||
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
|
||||
/// Whether the peer is on a direct BLE link right now.
|
||||
let isPeerConnected: (PeerID) -> Bool
|
||||
/// Delivers a fully received, hash-verified payload (encoded
|
||||
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
|
||||
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
|
||||
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
|
||||
/// unchanged (chunks report as "fragments").
|
||||
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
|
||||
let progressChunkSent: (_ transferId: String) -> Void
|
||||
/// Silently forgets progress state ahead of a BLE fallback re-start.
|
||||
let progressReset: (_ transferId: String) -> Void
|
||||
/// Emits the cancelled event for user-cancelled transfers.
|
||||
let progressCancel: (_ transferId: String) -> Void
|
||||
}
|
||||
|
||||
/// Knobs with test overrides; production values come from `TransportConfig`.
|
||||
struct WifiBulkTransferServiceConfig {
|
||||
var serviceType: String = TransportConfig.wifiBulkServiceType
|
||||
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
|
||||
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
|
||||
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
|
||||
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||
/// Tests disable peer-to-peer so loopback interfaces stay usable.
|
||||
var usePeerToPeer: Bool = true
|
||||
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
|
||||
var publishBonjourService: Bool = true
|
||||
}
|
||||
|
||||
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
|
||||
/// response (control plane), then the payload crosses a per-transfer TCP
|
||||
/// channel over AWDL, sealed with a key both sides derived from the
|
||||
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
|
||||
/// fragmentation exactly once; the receiver side fails silently and lets the
|
||||
/// sender's timeout drive that fallback.
|
||||
final class WifiBulkTransferService {
|
||||
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
|
||||
private let environment: WifiBulkTransferServiceEnvironment
|
||||
private let config: WifiBulkTransferServiceConfig
|
||||
|
||||
private final class OutgoingTransfer {
|
||||
let transferID: Data
|
||||
let transferId: String
|
||||
let peerID: PeerID
|
||||
let token: Data
|
||||
let fallback: () -> Void
|
||||
var session: WifiBulkSenderSession?
|
||||
var offerTimeout: DispatchWorkItem?
|
||||
var windowTimeout: DispatchWorkItem?
|
||||
var accepted = false
|
||||
var finished = false
|
||||
|
||||
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
|
||||
self.transferID = transferID
|
||||
self.transferId = transferId
|
||||
self.peerID = peerID
|
||||
self.token = token
|
||||
self.fallback = fallback
|
||||
}
|
||||
}
|
||||
|
||||
private final class IncomingTransfer {
|
||||
let offer: WifiBulkOffer
|
||||
let peerID: PeerID
|
||||
let key: SymmetricKey
|
||||
var browser: NWBrowser?
|
||||
var session: WifiBulkReceiverSession?
|
||||
var windowTimeout: DispatchWorkItem?
|
||||
|
||||
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
|
||||
self.offer = offer
|
||||
self.peerID = peerID
|
||||
self.key = key
|
||||
}
|
||||
}
|
||||
|
||||
private var outgoing: [Data: OutgoingTransfer] = [:]
|
||||
private var incoming: [Data: IncomingTransfer] = [:]
|
||||
|
||||
init(
|
||||
environment: WifiBulkTransferServiceEnvironment,
|
||||
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
|
||||
) {
|
||||
self.environment = environment
|
||||
self.config = config
|
||||
}
|
||||
|
||||
// MARK: - Sender
|
||||
|
||||
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
|
||||
/// most once, on decline, timeout, or any mid-transfer error.
|
||||
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||
queue.async { [weak self] in
|
||||
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a decrypted `bulkTransferResponse` Noise payload.
|
||||
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.processResponse(payload, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
|
||||
func cancelTransfer(transferId: String) {
|
||||
queue.async { [weak self] in
|
||||
guard let self,
|
||||
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
|
||||
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tears down every transfer (service shutdown / emergency disconnect).
|
||||
/// In-flight outgoing transfers do NOT fall back — the transport is going away.
|
||||
func stop() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
for transfer in self.outgoing.values {
|
||||
transfer.finished = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.session?.cancel()
|
||||
}
|
||||
self.outgoing.removeAll()
|
||||
for transfer in self.incoming.values {
|
||||
self.tearDownIncomingResources(transfer)
|
||||
}
|
||||
self.incoming.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private enum OutgoingOutcome {
|
||||
case completed
|
||||
case fallback
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
|
||||
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||
// Random per-transfer instance name — never the nickname or peer ID.
|
||||
let serviceName = Self.randomData(16).hexEncodedString()
|
||||
|
||||
let offer = WifiBulkOffer(
|
||||
transferID: transferID,
|
||||
fileSize: UInt64(payload.count),
|
||||
payloadHash: Data(SHA256.hash(data: payload)),
|
||||
token: token,
|
||||
serviceName: serviceName
|
||||
)
|
||||
guard let offerData = offer.encode() else {
|
||||
SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport)
|
||||
fallbackToBLE()
|
||||
return
|
||||
}
|
||||
|
||||
let transfer = OutgoingTransfer(
|
||||
transferID: transferID,
|
||||
transferId: transferId,
|
||||
peerID: peerID,
|
||||
token: token,
|
||||
fallback: fallbackToBLE
|
||||
)
|
||||
|
||||
let session = WifiBulkSenderSession(
|
||||
payload: payload,
|
||||
transferID: transferID,
|
||||
chunkBytes: config.chunkBytes,
|
||||
parameters: makeParameters(),
|
||||
service: config.publishBonjourService
|
||||
? NWListener.Service(name: serviceName, type: config.serviceType)
|
||||
: nil,
|
||||
queue: queue
|
||||
)
|
||||
if let onListenerReady = _test_onListenerReady {
|
||||
session.onListenerReady = { port in onListenerReady(transferID, port) }
|
||||
}
|
||||
session.onChunkSent = { [weak self, weak transfer] sent, total in
|
||||
guard let self, let transfer, !transfer.finished else { return }
|
||||
// Hold the final tick until the receipt confirms delivery, so the
|
||||
// progress bus only emits .completed for verified transfers.
|
||||
if sent < total {
|
||||
self.environment.progressChunkSent(transfer.transferId)
|
||||
}
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] in
|
||||
guard let self, let transfer, !transfer.finished else { return }
|
||||
self.environment.progressChunkSent(transfer.transferId)
|
||||
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
|
||||
}
|
||||
session.onFailed = { [weak self, weak transfer] reason in
|
||||
guard let self, let transfer else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
|
||||
}
|
||||
transfer.session = session
|
||||
outgoing[transferID] = transfer
|
||||
|
||||
guard session.start() else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
|
||||
return
|
||||
}
|
||||
guard environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
|
||||
peerID
|
||||
) else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .transport)
|
||||
environment.progressStart(transferId, session.totalChunks)
|
||||
|
||||
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer, !transfer.accepted else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
|
||||
}
|
||||
transfer.offerTimeout = offerTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||
}
|
||||
|
||||
private func processResponse(_ payload: Data, from peerID: PeerID) {
|
||||
guard let response = WifiBulkResponse.decode(payload),
|
||||
let transfer = outgoing[response.transferID],
|
||||
transfer.peerID.toShort() == peerID.toShort(),
|
||||
!transfer.accepted, !transfer.finished else {
|
||||
return
|
||||
}
|
||||
|
||||
guard response.accepted, let receiverToken = response.token else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
|
||||
return
|
||||
}
|
||||
guard let key = WifiBulkCrypto.deriveKey(
|
||||
senderToken: transfer.token,
|
||||
receiverToken: receiverToken,
|
||||
transferID: transfer.transferID
|
||||
) else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
|
||||
return
|
||||
}
|
||||
|
||||
transfer.accepted = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.offerTimeout = nil
|
||||
SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport)
|
||||
transfer.session?.activate(key: key)
|
||||
}
|
||||
|
||||
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
|
||||
guard !transfer.finished else { return }
|
||||
transfer.finished = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.session?.cancel()
|
||||
outgoing.removeValue(forKey: transfer.transferID)
|
||||
|
||||
switch outcome {
|
||||
case .completed:
|
||||
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
|
||||
case .fallback:
|
||||
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
|
||||
environment.progressReset(transfer.transferId)
|
||||
transfer.fallback()
|
||||
case .cancelled:
|
||||
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
|
||||
environment.progressCancel(transfer.transferId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receiver
|
||||
|
||||
/// Handles a decrypted `bulkTransferOffer` Noise payload.
|
||||
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.processOffer(payload, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func processOffer(_ payload: Data, from peerID: PeerID) {
|
||||
guard let offer = WifiBulkOffer.decode(payload) else { return }
|
||||
guard incoming[offer.transferID] == nil else { return }
|
||||
|
||||
guard WifiBulkPolicy.shouldAccept(
|
||||
offer: offer,
|
||||
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
|
||||
activeIncomingTransfers: incoming.count,
|
||||
maxPayloadBytes: config.maxIncomingPayloadBytes,
|
||||
maxConcurrentIncoming: config.maxConcurrentIncoming
|
||||
) else {
|
||||
decline(offer: offer, peerID: peerID)
|
||||
return
|
||||
}
|
||||
|
||||
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||
guard let key = WifiBulkCrypto.deriveKey(
|
||||
senderToken: offer.token,
|
||||
receiverToken: token,
|
||||
transferID: offer.transferID
|
||||
),
|
||||
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
|
||||
decline(offer: offer, peerID: peerID)
|
||||
return
|
||||
}
|
||||
guard environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
peerID
|
||||
) else {
|
||||
return // No session to answer on; the sender's timeout handles fallback.
|
||||
}
|
||||
|
||||
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||
incoming[offer.transferID] = transfer
|
||||
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||
|
||||
startBrowsing(for: transfer)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||
}
|
||||
|
||||
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||
SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
|
||||
_ = environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
peerID
|
||||
)
|
||||
}
|
||||
|
||||
private func startBrowsing(for transfer: IncomingTransfer) {
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: config.serviceType, domain: nil),
|
||||
using: makeParameters()
|
||||
)
|
||||
transfer.browser = browser
|
||||
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
|
||||
guard let self, let transfer, transfer.session == nil else { return }
|
||||
let match = results.first { result in
|
||||
if case .service(let name, _, _, _) = result.endpoint {
|
||||
return name == transfer.offer.serviceName
|
||||
}
|
||||
return false
|
||||
}
|
||||
guard let match else { return }
|
||||
self.connect(transfer, to: match.endpoint)
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self, weak transfer] state in
|
||||
guard let self, let transfer else { return }
|
||||
if case .failed(let error) = state {
|
||||
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
}
|
||||
browser.start(queue: queue)
|
||||
}
|
||||
|
||||
/// Test hook: connects an accepted incoming transfer straight to an
|
||||
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
|
||||
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
|
||||
queue.async { [weak self] in
|
||||
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
|
||||
self.connect(transfer, to: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
|
||||
transfer.browser?.cancel()
|
||||
transfer.browser = nil
|
||||
|
||||
guard let session = WifiBulkReceiverSession(
|
||||
endpoint: endpoint,
|
||||
parameters: makeParameters(),
|
||||
key: transfer.key,
|
||||
transferID: transfer.offer.transferID,
|
||||
expectedSize: transfer.offer.fileSize,
|
||||
expectedHash: transfer.offer.payloadHash,
|
||||
sizeCap: config.maxIncomingPayloadBytes,
|
||||
chunkBytes: config.chunkBytes,
|
||||
queue: queue
|
||||
) else {
|
||||
tearDownIncoming(transfer)
|
||||
return
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] payload in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport)
|
||||
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
session.onFailed = { [weak self, weak transfer] reason in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.session = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
private func tearDownIncoming(_ transfer: IncomingTransfer) {
|
||||
tearDownIncomingResources(transfer)
|
||||
incoming.removeValue(forKey: transfer.offer.transferID)
|
||||
}
|
||||
|
||||
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.windowTimeout = nil
|
||||
transfer.browser?.cancel()
|
||||
transfer.browser = nil
|
||||
transfer.session?.cancel()
|
||||
transfer.session = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeParameters() -> NWParameters {
|
||||
let parameters = NWParameters.tcp
|
||||
if config.usePeerToPeer {
|
||||
parameters.includePeerToPeer = true
|
||||
// Keep the channel off infrastructure-independent radios we never
|
||||
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
|
||||
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
|
||||
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
|
||||
private static func randomData(_ count: Int) -> Data {
|
||||
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
|
||||
}
|
||||
|
||||
// MARK: - Test observability
|
||||
|
||||
/// Test hook: reports each outgoing listener's bound port, standing in
|
||||
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
|
||||
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
|
||||
|
||||
var _test_activeOutgoingCount: Int {
|
||||
queue.sync { outgoing.count }
|
||||
}
|
||||
|
||||
var _test_activeIncomingCount: Int {
|
||||
queue.sync { incoming.count }
|
||||
}
|
||||
}
|
||||
@@ -212,10 +212,10 @@ final class GossipSyncManager {
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
// Group messages share the whole-message window: members off the mesh
|
||||
// for a while should backfill their crew's history like public chat.
|
||||
let maxAgeSeconds: TimeInterval
|
||||
switch packet.type {
|
||||
// Group messages share the whole-message window: members off the mesh
|
||||
// for a while should backfill their crew's history like public chat.
|
||||
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||
case MessageType.prekeyBundle.rawValue:
|
||||
@@ -275,13 +275,6 @@ 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
|
||||
@@ -305,6 +298,13 @@ 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,7 +313,6 @@ 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)
|
||||
}
|
||||
@@ -350,7 +349,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.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
|
||||
SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
|
||||
}
|
||||
@@ -391,7 +390,7 @@ final class GossipSyncManager {
|
||||
// A response can replay the whole store, so bound how often one peer
|
||||
// can trigger a diff pass regardless of how fast it asks.
|
||||
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
||||
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
return
|
||||
}
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
@@ -399,6 +398,9 @@ 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 {
|
||||
@@ -418,6 +420,7 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,6 +435,7 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -456,6 +460,7 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,23 +475,11 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.groupMessage) {
|
||||
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in groupPkts {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||
// 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
|
||||
@@ -501,9 +494,26 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.groupMessage) {
|
||||
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in groupPkts {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.boardPost) {
|
||||
// The board store already filters to live posts and tombstones;
|
||||
// no freshness window applies (posts sync until their own expiry).
|
||||
@@ -516,9 +526,14 @@ 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
|
||||
@@ -538,14 +553,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?() ?? [])
|
||||
}
|
||||
@@ -610,10 +625,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)
|
||||
@@ -662,6 +677,7 @@ 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;
|
||||
@@ -670,8 +686,14 @@ 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
|
||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||
// sync would only produce stale, unanswerable echoes.
|
||||
case .ping, .pong: 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
|
||||
// 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
|
||||
// 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
|
||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||
// sync would only produce stale, unanswerable echoes.
|
||||
case .ping, .pong: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,15 +115,6 @@ 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] = []
|
||||
@@ -159,4 +150,19 @@ 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: ",")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@ enum ChatBluetoothAlertPolicy {
|
||||
case .poweredOff:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
||||
message: String(localized: "content.alert.bluetooth_required.off", defaultValue: "bluetooth is turned off. please turn on bluetooth in settings to use bitchat.", comment: "Message shown when Bluetooth is turned off")
|
||||
)
|
||||
case .unauthorized:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
|
||||
message: String(localized: "content.alert.bluetooth_required.permission", defaultValue: "bitchat needs bluetooth permission to connect with nearby devices. please enable bluetooth access in settings.", comment: "Message shown when Bluetooth permission is missing")
|
||||
)
|
||||
case .unsupported:
|
||||
ChatBluetoothAlertUpdate(
|
||||
isPresented: true,
|
||||
message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
|
||||
message: String(localized: "content.alert.bluetooth_required.unsupported", defaultValue: "this device does not support bluetooth. bitchat requires bluetooth to function.", comment: "Message shown when the device lacks Bluetooth support")
|
||||
)
|
||||
case .poweredOn:
|
||||
ChatBluetoothAlertUpdate(isPresented: false, message: "")
|
||||
|
||||
@@ -159,26 +159,26 @@ final class ChatGroupCoordinator {
|
||||
func createGroup(named rawName: String) -> CommandResult {
|
||||
let name = rawName.trimmed
|
||||
guard !name.isEmpty else {
|
||||
return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create"))
|
||||
return .error(message: String(localized: "system.group.usage_create", defaultValue: "usage: /group create <name>", comment: "Usage hint for /group create"))
|
||||
}
|
||||
guard name.count <= Self.maxGroupNameLength else {
|
||||
return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap"))
|
||||
return .error(message: String(localized: "system.group.name_too_long", defaultValue: "group names are limited to 40 characters", comment: "Error when a group name exceeds the length cap"))
|
||||
}
|
||||
|
||||
let myFingerprint = context.myNoiseFingerprint()
|
||||
let mySigningKey = context.mySigningPublicKey()
|
||||
guard !myFingerprint.isEmpty, mySigningKey.count == 32 else {
|
||||
return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations"))
|
||||
return .error(message: String(localized: "system.group.identity_unavailable", defaultValue: "your identity keys are not ready yet", comment: "Error when the local identity is not ready for group operations"))
|
||||
}
|
||||
|
||||
let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname)
|
||||
guard let group = context.groupStore.createGroup(named: name, creator: creator) else {
|
||||
return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails"))
|
||||
return .error(message: String(localized: "system.group.create_failed", defaultValue: "could not create the group", comment: "Error when group creation fails"))
|
||||
}
|
||||
|
||||
context.startPrivateChat(with: group.peerID)
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"),
|
||||
format: String(localized: "system.group.created", defaultValue: "created group '%@' — use /group invite @name to add people", comment: "System message after creating a group; placeholder is the group name"),
|
||||
locale: .current,
|
||||
name
|
||||
))
|
||||
@@ -187,45 +187,45 @@ final class ChatGroupCoordinator {
|
||||
func inviteMember(nickname rawNickname: String) -> CommandResult {
|
||||
let nickname = normalizedNickname(rawNickname)
|
||||
guard !nickname.isEmpty else {
|
||||
return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite"))
|
||||
return .error(message: String(localized: "system.group.usage_invite", defaultValue: "usage: /group invite @name", comment: "Usage hint for /group invite"))
|
||||
}
|
||||
guard let group = selectedGroup() else {
|
||||
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
|
||||
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
|
||||
}
|
||||
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
|
||||
return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||
return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||
}
|
||||
guard let peerID = context.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
|
||||
format: String(localized: "system.group.peer_not_found", defaultValue: "'%@' not found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard context.isPeerConnected(peerID) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
|
||||
format: String(localized: "system.group.peer_not_connected", defaultValue: "%@ must be connected over mesh to be invited", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard let identity = context.cryptoIdentity(for: peerID) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
|
||||
format: String(localized: "system.group.peer_identity_unknown", defaultValue: "cannot verify %@'s identity yet — wait for their announce", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard !group.isMember(fingerprint: identity.fingerprint) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
|
||||
format: String(localized: "system.group.already_member", defaultValue: "%@ is already a member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard group.members.count < BitchatGroup.maxMembers else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"),
|
||||
format: String(localized: "system.group.full", defaultValue: "group is full (max %@ members)", comment: "Error when the group is at the member cap; placeholder is the cap"),
|
||||
locale: .current,
|
||||
"\(BitchatGroup.maxMembers)"
|
||||
))
|
||||
@@ -243,14 +243,14 @@ final class ChatGroupCoordinator {
|
||||
let members = group.members + [newMember]
|
||||
guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members),
|
||||
let payload = signedStatePayload(for: updated, key: key) else {
|
||||
return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails"))
|
||||
return .error(message: String(localized: "system.group.invite_failed", defaultValue: "could not build the group invite", comment: "Error when building or signing a group invite fails"))
|
||||
}
|
||||
|
||||
context.sendGroupInvitePayload(payload, to: peerID)
|
||||
distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate)
|
||||
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
|
||||
format: String(localized: "system.group.invited", defaultValue: "invited %1$@ to '%2$@'", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
|
||||
locale: .current,
|
||||
nickname,
|
||||
updated.name
|
||||
@@ -263,36 +263,36 @@ final class ChatGroupCoordinator {
|
||||
func removeMember(nickname rawNickname: String) -> CommandResult {
|
||||
let nickname = normalizedNickname(rawNickname)
|
||||
guard !nickname.isEmpty else {
|
||||
return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove"))
|
||||
return .error(message: String(localized: "system.group.usage_remove", defaultValue: "usage: /group remove @name", comment: "Usage hint for /group remove"))
|
||||
}
|
||||
guard let group = selectedGroup() else {
|
||||
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
|
||||
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
|
||||
}
|
||||
guard group.creatorFingerprint == context.myNoiseFingerprint() else {
|
||||
return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||
return .error(message: String(localized: "system.group.creator_only", defaultValue: "only the group creator can do that", comment: "Error when a non-creator attempts a creator-only group action"))
|
||||
}
|
||||
guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else {
|
||||
return .error(message: String(
|
||||
format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
|
||||
format: String(localized: "system.group.member_not_found", defaultValue: "'%@' is not a member of this group", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
))
|
||||
}
|
||||
guard member.fingerprint != group.creatorFingerprint else {
|
||||
return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves"))
|
||||
return .error(message: String(localized: "system.group.cannot_remove_creator", defaultValue: "the creator cannot be removed", comment: "Error when the creator tries to remove themselves"))
|
||||
}
|
||||
|
||||
let remaining = group.members.filter { $0.fingerprint != member.fingerprint }
|
||||
guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining),
|
||||
let payload = signedStatePayload(for: rotated, key: newKey) else {
|
||||
return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails"))
|
||||
return .error(message: String(localized: "system.group.rotate_failed", defaultValue: "could not rotate the group key", comment: "Error when rotating the group key fails"))
|
||||
}
|
||||
|
||||
distributeState(payload, group: rotated, excluding: [], type: .keyUpdate)
|
||||
notifyRemovedMember(member, rotated: rotated)
|
||||
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
|
||||
format: String(localized: "system.group.removed_member", defaultValue: "removed %@ and rotated the group key", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
|
||||
locale: .current,
|
||||
member.nickname
|
||||
))
|
||||
@@ -300,7 +300,7 @@ final class ChatGroupCoordinator {
|
||||
|
||||
func leaveGroup() -> CommandResult {
|
||||
guard let group = selectedGroup() else {
|
||||
return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
|
||||
return .error(message: String(localized: "system.group.not_in_group", defaultValue: "open a group chat first", comment: "Error when a group command requires an open group chat"))
|
||||
}
|
||||
// Close the chat window first so the confirmation message doesn't
|
||||
// resurrect the conversation we're about to remove.
|
||||
@@ -309,7 +309,7 @@ final class ChatGroupCoordinator {
|
||||
context.groupStore.removeGroup(withID: group.groupID)
|
||||
context.notifyUIChanged()
|
||||
return .success(message: String(
|
||||
format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"),
|
||||
format: String(localized: "system.group.left", defaultValue: "left group '%@'", comment: "System message after leaving a group; placeholder is the group name"),
|
||||
locale: .current,
|
||||
group.name
|
||||
))
|
||||
@@ -318,14 +318,14 @@ final class ChatGroupCoordinator {
|
||||
func listGroups() -> CommandResult {
|
||||
let groups = context.groupStore.groups
|
||||
guard !groups.isEmpty else {
|
||||
return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups"))
|
||||
return .success(message: String(localized: "system.group.none", defaultValue: "you are not in any groups — /group create <name> to start one", comment: "System message when the user is in no groups"))
|
||||
}
|
||||
let myFingerprint = context.myNoiseFingerprint()
|
||||
let lines = groups.map { group -> String in
|
||||
let role = group.creatorFingerprint == myFingerprint ? " (creator)" : ""
|
||||
return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)"
|
||||
}
|
||||
return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
|
||||
return .success(message: String(localized: "system.group.list_header", defaultValue: "your groups:", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
// MARK: - Sending
|
||||
@@ -336,7 +336,7 @@ final class ChatGroupCoordinator {
|
||||
guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
|
||||
guard let group = context.groupStore.group(for: groupPeerID),
|
||||
let key = context.groupStore.key(forGroupID: group.groupID) else {
|
||||
context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"))
|
||||
context.addSystemMessage(String(localized: "system.group.unknown", defaultValue: "you are no longer in this group", comment: "System message when sending into an unknown group"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ final class ChatGroupCoordinator {
|
||||
} catch {
|
||||
SecureLogger.error("Failed to seal group message: \(error)", category: .encryption)
|
||||
context.addLocalPrivateSystemMessage(
|
||||
String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"),
|
||||
String(localized: "system.group.send_failed", defaultValue: "could not encrypt the message", comment: "System message when sealing a group message fails"),
|
||||
to: groupPeerID
|
||||
)
|
||||
return
|
||||
@@ -559,7 +559,7 @@ private extension ChatGroupCoordinator {
|
||||
context.removePrivateChat(existing.peerID)
|
||||
context.groupStore.removeGroup(withID: existing.groupID)
|
||||
context.addSystemMessage(String(
|
||||
format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"),
|
||||
format: String(localized: "system.group.removed_from", defaultValue: "you were removed from group '%@'", comment: "System message when removed from a group; placeholder is the group name"),
|
||||
locale: .current,
|
||||
existing.name
|
||||
))
|
||||
@@ -586,7 +586,7 @@ private extension ChatGroupCoordinator {
|
||||
?? context.peerNickname(for: peerID)
|
||||
?? "?"
|
||||
let notice = String(
|
||||
format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"),
|
||||
format: String(localized: "system.group.joined", defaultValue: "you were added to group '%1$@' by %2$@ — it now appears in your people list", comment: "System message when added to a group; placeholders are the group name and the inviter"),
|
||||
locale: .current,
|
||||
state.name,
|
||||
inviter
|
||||
|
||||
@@ -350,7 +350,7 @@ private extension ChatLifecycleCoordinator {
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", defaultValue: "voice note too large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", defaultValue: "voice note failed to send", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ private extension ChatOutgoingCoordinator {
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -181,7 +181,7 @@ private extension ChatOutgoingCoordinator {
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||
context?.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
String(localized: "system.location.send_failed", defaultValue: "failed to send to location channel", comment: "System message when a location channel send fails")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,29 +227,10 @@ 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 }
|
||||
|
||||
@@ -257,7 +238,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
let nickname = context.peerNickname(for: peerID) ?? "user"
|
||||
context.addSystemMessage(
|
||||
String(
|
||||
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
|
||||
format: String(localized: "system.dm.blocked_recipient", defaultValue: "cannot send message to %@: person is blocked.", comment: "System message when attempting to message a blocked user"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
@@ -317,7 +298,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
} else {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
||||
reason: String(localized: "content.delivery.reason.unreachable", defaultValue: "peer not reachable", comment: "Failure reason when a peer is unreachable")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -325,7 +306,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
let name = recipientNickname ?? "user"
|
||||
context.addSystemMessage(
|
||||
String(
|
||||
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
|
||||
format: String(localized: "system.dm.unreachable", defaultValue: "cannot send message to %@ - peer is not reachable via mesh or nostr.", comment: "System message when a recipient is unreachable"),
|
||||
locale: .current,
|
||||
name
|
||||
)
|
||||
@@ -336,7 +317,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
||||
guard case .location(let channel) = context.activeChannel else {
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
|
||||
String(localized: "system.location.not_in_channel", defaultValue: "cannot send: not in a location channel", comment: "System message when attempting to send without being in a location channel")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -360,7 +341,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
||||
reason: String(localized: "content.delivery.reason.unknown_recipient", defaultValue: "unknown recipient", comment: "Failure reason when the recipient is unknown")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -371,13 +352,13 @@ final class ChatPrivateConversationCoordinator {
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
||||
reason: String(localized: "content.delivery.reason.blocked", defaultValue: "user is blocked", comment: "Failure reason when the user is blocked")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
||||
String(localized: "system.dm.blocked_generic", defaultValue: "cannot send message: person is blocked.", comment: "System message when sending fails because user is blocked")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -387,7 +368,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
||||
reason: String(localized: "content.delivery.reason.self", defaultValue: "cannot message yourself", comment: "Failure reason when attempting to message yourself")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -409,7 +390,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
} catch {
|
||||
context.setPrivateDeliveryStatus(
|
||||
.failed(
|
||||
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
||||
reason: String(localized: "content.delivery.reason.send_error", defaultValue: "send error", comment: "Failure reason for a generic send error")
|
||||
),
|
||||
forMessageID: messageID,
|
||||
peerID: peerID
|
||||
@@ -427,15 +408,10 @@ final class ChatPrivateConversationCoordinator {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
|
||||
// Ack before the dedup guard: a re-sent copy means the sender may not
|
||||
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
|
||||
// actual sends.
|
||||
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||
|
||||
guard markInboundGeoDMSeen(messageId) else { return }
|
||||
|
||||
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
||||
|
||||
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
||||
return
|
||||
}
|
||||
@@ -514,10 +490,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
// 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)
|
||||
SecureLogger.warning("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,6 +135,10 @@ 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)
|
||||
}
|
||||
@@ -142,10 +146,6 @@ 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,14 +389,19 @@ 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 .vouch:
|
||||
context.handleVouchPayload(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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,6 @@ 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)
|
||||
@@ -90,10 +86,6 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
peerIdentityStore.setVerified(fingerprint, verified: verified)
|
||||
}
|
||||
|
||||
func vouchToConnectedVerifiedPeers() {
|
||||
vouchCoordinator.vouchToConnectedVerifiedPeers()
|
||||
}
|
||||
|
||||
var unifiedPeers: [BitchatPeer] {
|
||||
unifiedPeerService.peers
|
||||
}
|
||||
@@ -156,9 +148,6 @@ 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) {
|
||||
@@ -351,8 +340,6 @@ 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 groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||
|
||||
// Computed properties for compatibility
|
||||
@MainActor
|
||||
@@ -1215,12 +1215,14 @@ 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()
|
||||
@@ -1293,12 +1295,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
// 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.
|
||||
// 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.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
@@ -1650,12 +1650,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||
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
|
||||
@@ -1744,19 +1738,6 @@ 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,11 +41,7 @@ struct ChatViewModelServiceBundle {
|
||||
self.privateChatManager = privateChatManager
|
||||
self.unifiedPeerService = unifiedPeerService
|
||||
self.autocompleteService = AutocompleteService()
|
||||
// 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.deduplicationService = MessageDeduplicationService()
|
||||
self.publicMessagePipeline = PublicMessagePipeline()
|
||||
}
|
||||
}
|
||||
@@ -111,7 +107,7 @@ private extension ChatViewModelBootstrapper {
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(
|
||||
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
.failed(reason: String(localized: "content.delivery.reason.not_delivered", defaultValue: "not delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
forMessageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ 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)
|
||||
@@ -77,10 +74,6 @@ extension ChatViewModel: ChatVouchContext {
|
||||
meshService.peerCapabilities(peerID)
|
||||
}
|
||||
|
||||
func connectedPeerIDs() -> [PeerID] {
|
||||
Array(connectedPeers)
|
||||
}
|
||||
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
meshService.addPeerAuthenticatedObserver(handler)
|
||||
}
|
||||
@@ -127,70 +120,16 @@ final class ChatVouchCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// 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`.
|
||||
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
|
||||
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 }
|
||||
|
||||
guard context.isVerifiedFingerprint(fingerprint) else { return }
|
||||
guard context.peerCapabilities(for: peerID).contains(.vouch) else { return }
|
||||
if let lastSent = context.lastVouchBatchSent(to: fingerprint),
|
||||
now.timeIntervalSince(lastSent) < Self.batchInterval {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
let candidates = context.recentlyVerifiedFingerprints(
|
||||
@@ -217,14 +156,13 @@ final class ChatVouchCoordinator {
|
||||
}
|
||||
|
||||
guard !attestations.isEmpty,
|
||||
let payload = VouchAttestation.encodeList(attestations) else { return false }
|
||||
let payload = VouchAttestation.encodeList(attestations) else { return }
|
||||
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,
|
||||
|
||||
@@ -19,7 +19,7 @@ extension ChatViewModel {
|
||||
self.torStatusAnnounced = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
String(localized: "system.tor.starting", defaultValue: "starting tor...", comment: "System message when Tor is starting")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ extension ChatViewModel {
|
||||
self.torRestartPending = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
|
||||
String(localized: "system.tor.restarting", defaultValue: "tor restarting to recover connectivity...", comment: "System message when Tor is restarting")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,13 @@ extension ChatViewModel {
|
||||
if self.torRestartPending {
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
|
||||
String(localized: "system.tor.restarted", defaultValue: "tor restarted. network routing restored.", comment: "System message when Tor has restarted")
|
||||
)
|
||||
self.torRestartPending = false
|
||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
||||
// Initial start completed
|
||||
self.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.started", comment: "System message when Tor has started")
|
||||
String(localized: "system.tor.started", defaultValue: "tor started. routing all chats via tor for IP privacy.", comment: "System message when Tor has started")
|
||||
)
|
||||
self.torInitialReadyAnnounced = true
|
||||
}
|
||||
|
||||
@@ -304,8 +304,10 @@ 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.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
// 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:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -358,8 +360,10 @@ 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.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
// 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:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -439,8 +443,11 @@ 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.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
|
||||
// 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:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
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
|
||||
@@ -10,6 +18,14 @@ 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
|
||||
}
|
||||
@@ -128,7 +144,7 @@ struct AppInfoView: View {
|
||||
// Custom header for macOS
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("app_info.done") {
|
||||
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -240,7 +256,7 @@ struct AppInfoView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text("app_info.network.topology.hint"))
|
||||
.accessibilityHint(Text(String(localized: "app_info.network.topology.hint", defaultValue: "opens the mesh topology map")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,11 +309,122 @@ 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
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//
|
||||
// BoardView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The bulletin board for one context: a geohash channel, or the mesh-local
|
||||
/// board when `geohash` is empty. Urgent posts pin to the top; own posts can
|
||||
/// be swipe-deleted, which broadcasts a signed tombstone.
|
||||
struct BoardView: View {
|
||||
/// Empty string = mesh-local board.
|
||||
let geohash: String
|
||||
let senderNickname: String
|
||||
@ObservedObject var board: BoardManager
|
||||
|
||||
@ThemedPalette private var palette
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
@State private var urgent = false
|
||||
@State private var expiryDays = 7
|
||||
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) }
|
||||
|
||||
private enum Strings {
|
||||
static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet")
|
||||
static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title")
|
||||
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
||||
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
||||
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
||||
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
||||
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
||||
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
||||
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
||||
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
||||
static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button")
|
||||
|
||||
static func expiryDaysOption(_ days: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
||||
locale: .current,
|
||||
days
|
||||
)
|
||||
}
|
||||
|
||||
static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
||||
let base = String(
|
||||
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
||||
locale: .current,
|
||||
author, content
|
||||
)
|
||||
return urgent ? "\(urgentBadge), \(base)" : base
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
postList
|
||||
composer
|
||||
}
|
||||
.themedSurface()
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
#endif
|
||||
.themedSheetBackground()
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)")
|
||||
.bitchatFont(size: 18)
|
||||
Spacer()
|
||||
SheetCloseButton { dismiss() }
|
||||
.accessibilityLabel(Strings.closeHint)
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private var postList: some View {
|
||||
Group {
|
||||
if posts.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Strings.emptyTitle)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.emptySubtitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(posts, id: \.postID) { post in
|
||||
postRow(post)
|
||||
.listRowBackground(palette.background)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private func postRow(_ post: BoardPostPacket) -> some View {
|
||||
let isOwn = board.isOwnPost(post)
|
||||
let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if post.isUrgent {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.bitchatSystem(size: 11))
|
||||
.foregroundColor(palette.alertRed)
|
||||
Text(Strings.urgentBadge)
|
||||
.bitchatFont(size: 11, weight: .semibold)
|
||||
.foregroundColor(palette.alertRed)
|
||||
}
|
||||
Text(verbatim: "@\(author)")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
Text(timestampText(forMs: post.createdAt))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
if isOwn {
|
||||
Button {
|
||||
board.deletePost(post)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.deleteAction)
|
||||
}
|
||||
}
|
||||
Text(post.content)
|
||||
.bitchatFont(size: 14)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent))
|
||||
.accessibilityActions {
|
||||
if isOwn {
|
||||
Button(Strings.deleteAction) { board.deletePost(post) }
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if isOwn {
|
||||
Button(role: .destructive) {
|
||||
board.deletePost(post)
|
||||
} label: {
|
||||
Label(Strings.deleteAction, systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.bitchatFont(size: 14)
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
.padding(.vertical, 6)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!sendEnabled)
|
||||
.accessibilityLabel(Strings.send)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Toggle(isOn: $urgent) {
|
||||
Text(Strings.urgentToggle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.urgentToggle)
|
||||
Spacer()
|
||||
Text(Strings.expiryLabel)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
||||
ForEach([1, 3, 7], id: \.self) { days in
|
||||
Text(Strings.expiryDaysOption(days)).tag(days)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.expiryLabel)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.themedSurface()
|
||||
.overlay(Divider(), alignment: .top)
|
||||
}
|
||||
|
||||
private var sendEnabled: Bool {
|
||||
let trimmed = draft.trimmed
|
||||
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
let sent = board.createPost(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
urgent: urgent,
|
||||
expiryDays: expiryDays,
|
||||
nickname: senderNickname
|
||||
)
|
||||
if sent {
|
||||
draft = ""
|
||||
urgent = false
|
||||
}
|
||||
}
|
||||
|
||||
private func timestampText(forMs ms: UInt64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
let now = Date()
|
||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||
return rel.isEmpty ? "" : "\(rel) ago"
|
||||
}
|
||||
return Self.absDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private static let relativeFormatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
f.allowedUnits = [.day, .hour, .minute]
|
||||
f.maximumUnitCount = 1
|
||||
f.unitsStyle = .abbreviated
|
||||
f.collapsesLargestUnit = true
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -16,32 +16,32 @@ extension DeliveryStatus {
|
||||
var bitchatDescription: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
return String(localized: "content.delivery.sending", defaultValue: "sending...", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
return String(localized: "content.delivery.sent", defaultValue: "sent — no delivery confirmation yet", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
case .carried:
|
||||
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
|
||||
case .delivered(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
format: String(localized: "content.delivery.delivered_to", defaultValue: "delivered to %@", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .read(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
format: String(localized: "content.delivery.read_by", defaultValue: "read by %@", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .failed(let reason):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
format: String(localized: "content.delivery.failed", defaultValue: "failed: %@", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
format: String(localized: "content.delivery.delivered_members", defaultValue: "delivered to %1$d of %2$d members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
|
||||
@@ -70,9 +70,9 @@ struct PaymentChipView: View {
|
||||
var label: String {
|
||||
switch self {
|
||||
case .cashu:
|
||||
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||
String(localized: "content.payment.cashu", defaultValue: "pay via cashu", comment: "Label for Cashu payment chip")
|
||||
case .lightning:
|
||||
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||
String(localized: "content.payment.lightning", defaultValue: "pay via lightning", comment: "Label for Lightning payment chip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,18 +145,18 @@ struct PaymentChipView: View {
|
||||
Button {
|
||||
copyToPasteboard(token)
|
||||
} label: {
|
||||
Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
|
||||
Label(String(localized: "content.payment.copy_token", defaultValue: "copy token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
|
||||
}
|
||||
Button {
|
||||
redeemCashu()
|
||||
} label: {
|
||||
Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
|
||||
Label(String(localized: "content.payment.redeem_wallet", defaultValue: "redeem in wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
|
||||
}
|
||||
if let webURL = paymentType.cashuWebRedeemURL {
|
||||
Button {
|
||||
openExternalURL(webURL)
|
||||
} label: {
|
||||
Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
|
||||
Label(String(localized: "content.payment.redeem_web", defaultValue: "redeem on web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,6 @@ struct SheetCloseButton: View {
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
.accessibilityLabel(String(localized: "common.close", defaultValue: "close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ struct TextMessageView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,17 +118,17 @@ private extension ContentComposerView {
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
format: String(localized: "content.input.placeholder.private", defaultValue: "message %@ — private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
locale: .current,
|
||||
target
|
||||
)
|
||||
}
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
|
||||
return String(localized: "content.input.placeholder.mesh", defaultValue: "message #mesh — public, nearby", comment: "Composer placeholder for the public mesh channel")
|
||||
case .location(let channel):
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
format: String(localized: "content.input.placeholder.location", defaultValue: "message #%@ — public", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
@@ -184,15 +184,15 @@ private extension ContentComposerView {
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
|
||||
String(localized: "content.accessibility.attach_photo", defaultValue: "attach photo", comment: "Accessibility label for the photo attachment button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
String(localized: "content.accessibility.attach_photo_hint", defaultValue: "opens the photo library; use the take photo action for the camera", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// The long-press → camera path is unreachable for VoiceOver users;
|
||||
// mirror it as a named action.
|
||||
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
|
||||
.accessibilityAction(named: Text(String(localized: "content.accessibility.take_photo", defaultValue: "take photo with camera", comment: "Accessibility action name for taking a photo with the camera"))) {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
@@ -205,7 +205,7 @@ private extension ContentComposerView {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
String(localized: "content.accessibility.choose_photo", defaultValue: "choose photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
)
|
||||
#endif
|
||||
}
|
||||
@@ -249,15 +249,15 @@ private extension ContentComposerView {
|
||||
)
|
||||
)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
|
||||
String(localized: "content.accessibility.record_voice_note", defaultValue: "record voice note", comment: "Accessibility label for the voice note button")
|
||||
)
|
||||
.accessibilityValue(
|
||||
voiceRecordingVM.state.isActive
|
||||
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
? String(localized: "content.accessibility.recording", defaultValue: "recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
: ""
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
String(localized: "content.accessibility.record_voice_hint", defaultValue: "double-tap to start recording, double-tap again to send", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||
@@ -282,12 +282,12 @@ private extension ContentComposerView {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
|
||||
String(localized: "content.accessibility.send_message", defaultValue: "send message", comment: "Accessibility label for the send message button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
enabled
|
||||
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
|
||||
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
|
||||
? String(localized: "content.accessibility.send_hint_ready", defaultValue: "double tap to send", comment: "Hint prompting the user to send the message")
|
||||
: String(localized: "content.accessibility.send_hint_empty", defaultValue: "enter a message to send", comment: "Hint prompting the user to enter a message")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
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
|
||||
@@ -21,13 +25,8 @@ struct ContentHeaderView: View {
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
/// 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] = []
|
||||
/// Bulletin board sheet for the current channel context.
|
||||
@State private var showBoard = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -45,7 +44,7 @@ struct ContentHeaderView: View {
|
||||
// stays undiscoverable on purpose — it's destructive.)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
String(localized: "content.accessibility.app_info_hint", defaultValue: "shows app info", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
)
|
||||
.accessibilityAction {
|
||||
appChromeModel.presentAppInfo()
|
||||
@@ -57,7 +56,7 @@ struct ContentHeaderView: View {
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
TextField(
|
||||
"content.input.nickname_placeholder",
|
||||
String(localized: "content.input.nickname_placeholder", defaultValue: "nickname"),
|
||||
text: Binding(
|
||||
get: { appChromeModel.nickname },
|
||||
set: { appChromeModel.setNickname($0) }
|
||||
@@ -134,44 +133,40 @@ struct ContentHeaderView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
|
||||
String(localized: "content.accessibility.open_unread_private_chat", defaultValue: "open unread private chat", comment: "Accessibility label for the unread private chat button")
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
var scopes: Set<String> = [""]
|
||||
if let geoScope = noticesGeoScope {
|
||||
scopes.insert(geoScope)
|
||||
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()
|
||||
}
|
||||
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")
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_notes", defaultValue: "location notes for this place", comment: "Accessibility label for location notes button")
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { showBoard = true }) {
|
||||
Image(systemName: "pin")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(
|
||||
scopeHasNotices || unseenNoticesCount > 0
|
||||
? Color.orange.opacity(0.8)
|
||||
: palette.secondary.opacity(0.9)
|
||||
)
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
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
|
||||
)
|
||||
: ""
|
||||
String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button")
|
||||
String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button")
|
||||
)
|
||||
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
@@ -183,7 +178,7 @@ struct ContentHeaderView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
|
||||
format: String(localized: "content.accessibility.toggle_bookmark", defaultValue: "toggle bookmark for #%@", comment: "Accessibility label for toggling a geohash bookmark"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
@@ -216,7 +211,7 @@ struct ContentHeaderView: View {
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
||||
String(localized: "content.accessibility.location_channels", defaultValue: "location channels", comment: "Accessibility label for the location channels button")
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -243,7 +238,7 @@ struct ContentHeaderView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
||||
format: String(localized: "content.accessibility.people_count", defaultValue: "%#@people@", comment: "Accessibility label announcing number of people in header"),
|
||||
locale: .current,
|
||||
headerOtherPeersCount
|
||||
)
|
||||
@@ -252,8 +247,8 @@ struct ContentHeaderView: View {
|
||||
// color; say it.
|
||||
.accessibilityValue(
|
||||
headerPeersReachable
|
||||
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
|
||||
? String(localized: "content.accessibility.peers_connected", defaultValue: "connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", defaultValue: "no one reachable", comment: "Accessibility value when no peers are reachable")
|
||||
)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
@@ -272,21 +267,54 @@ 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: $showNotices) {
|
||||
NoticesView(
|
||||
.sheet(isPresented: $showBoard) {
|
||||
BoardView(
|
||||
geohash: boardGeohash,
|
||||
senderNickname: appChromeModel.nickname,
|
||||
board: appChromeModel.boardManager,
|
||||
initialTab: initialNoticesTab
|
||||
board: appChromeModel.boardManager
|
||||
)
|
||||
.environmentObject(locationChannelsModel)
|
||||
}
|
||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||
notesGeohash = nil
|
||||
}) {
|
||||
Group {
|
||||
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
|
||||
LocationNotesView(
|
||||
geohash: geohash,
|
||||
senderNickname: appChromeModel.nickname
|
||||
)
|
||||
.environmentObject(locationChannelsModel)
|
||||
} else {
|
||||
ContentLocationNotesUnavailableView(
|
||||
showLocationNotes: $showLocationNotes,
|
||||
headerHeight: headerHeight
|
||||
)
|
||||
.environmentObject(locationChannelsModel)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
locationChannelsModel.enableLocationChannels()
|
||||
locationChannelsModel.beginLiveRefresh()
|
||||
}
|
||||
.onDisappear {
|
||||
locationChannelsModel.endLiveRefresh()
|
||||
}
|
||||
.onChange(of: locationChannelsModel.availableChannels) { channels in
|
||||
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
||||
notesGeohash != current {
|
||||
notesGeohash = current
|
||||
#if os(iOS)
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.prepare()
|
||||
generator.impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
@@ -297,10 +325,10 @@ struct ContentHeaderView: View {
|
||||
.onChange(of: locationChannelsModel.permissionState) { _ in
|
||||
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
||||
}
|
||||
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
.alert(String(localized: "content.alert.screenshot.title", defaultValue: "heads up"), isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
||||
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("content.alert.screenshot.message")
|
||||
Text(String(localized: "content.alert.screenshot.message", defaultValue: "screenshots of location channels will reveal your location. think before sharing publicly."))
|
||||
}
|
||||
.themedChromePanel(edge: .top)
|
||||
}
|
||||
@@ -320,34 +348,13 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
/// The board scope for the current channel: the geohash channel's board,
|
||||
/// or the mesh-local board ("") in mesh chat.
|
||||
var boardGeohash: String {
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return channel.geohash
|
||||
}
|
||||
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
|
||||
return ""
|
||||
}
|
||||
|
||||
/// Whether anyone is actually reachable on the current channel — the
|
||||
@@ -373,3 +380,37 @@ private extension ContentHeaderView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentLocationNotesUnavailableView: View {
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@ThemedPalette private var palette
|
||||
|
||||
@Binding var showLocationNotes: Bool
|
||||
|
||||
let headerHeight: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text(String(localized: "content.notes.title", defaultValue: "notes"))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
Spacer()
|
||||
SheetCloseButton { showLocationNotes = false }
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.themedChromePanel(edge: .top)
|
||||
Text(String(localized: "content.notes.location_unavailable", defaultValue: "location unavailable"))
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(String(localized: "content.location.enable", defaultValue: "enable location")) {
|
||||
locationChannelsModel.enableAndRefresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
}
|
||||
.themedSheetBackground()
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@ private struct ContentPeopleListView: View {
|
||||
// .help maps to the accessibility *hint* on iOS, so the
|
||||
// button still needs a spoken name.
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
|
||||
String(localized: "content.accessibility.verification", defaultValue: "verify encryption", comment: "Accessibility label for the verification QR button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
String(localized: "content.help.verification", defaultValue: "verification: show my QR or scan a friend", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
SheetCloseButton {
|
||||
@@ -262,7 +262,7 @@ private struct ContentPeopleListView: View {
|
||||
|
||||
private extension ContentPeopleListView {
|
||||
var peopleSheetTitle: String {
|
||||
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
|
||||
String(localized: "content.header.people", defaultValue: "PEOPLE", comment: "Title for the people list sheet").lowercased()
|
||||
}
|
||||
|
||||
var peopleSheetSubtitle: String? {
|
||||
@@ -329,7 +329,7 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
||||
String(localized: "content.accessibility.back_to_main_chat", defaultValue: "back to main chat", comment: "Accessibility label for returning to main chat")
|
||||
)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -354,8 +354,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
headerState.isFavorite
|
||||
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
? String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
|
||||
: String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,7 @@ private struct ContentPrivateChatSheetView: View {
|
||||
private var privacyCaptionText: String {
|
||||
// Group chats are ChaCha20-Poly1305 sealed to the roster's shared key.
|
||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||
return String(localized: "content.private.caption_group", defaultValue: "encrypted group · members only", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||
}
|
||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
@@ -477,9 +477,9 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
}()
|
||||
if isGeoDM || noiseSecured {
|
||||
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
return String(localized: "content.private.caption_encrypted", defaultValue: "private · end-to-end encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
}
|
||||
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||
return String(localized: "content.private.caption", defaultValue: "private conversation", comment: "Caption above the private chat composer before encryption is established")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,27 +523,27 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator"))
|
||||
.accessibilityLabel(String(localized: "content.accessibility.group_chat", defaultValue: "Group chat", comment: "Accessibility label for the group chat indicator"))
|
||||
} else {
|
||||
switch headerState.availability {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
||||
case .meshReachable:
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
||||
case .nostrAvailable:
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text("mesh_peers.state.offline")
|
||||
Text(String(localized: "mesh_peers.state.offline", defaultValue: "offline"))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
@@ -574,7 +574,7 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
||||
format: String(localized: "content.accessibility.encryption_status", defaultValue: "encryption status: %@", comment: "Accessibility label announcing encryption status"),
|
||||
locale: .current,
|
||||
encryptionStatus.accessibilityDescription
|
||||
)
|
||||
@@ -585,7 +585,7 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
||||
format: String(localized: "content.accessibility.private_chat_header", defaultValue: "private chat with %@", comment: "Accessibility label describing the private chat header"),
|
||||
locale: .current,
|
||||
headerState.displayName
|
||||
)
|
||||
@@ -593,7 +593,7 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.accessibilityHint(
|
||||
headerState.isGroupConversation
|
||||
? ""
|
||||
: String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
: String(localized: "content.accessibility.view_fingerprint_hint", defaultValue: "tap to view encryption fingerprint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(minHeight: headerHeight)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ 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
|
||||
@@ -202,20 +204,20 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||
if voiceRecordingVM.state == .permissionDenied {
|
||||
Button("location_channels.action.open_settings") {
|
||||
Button(String(localized: "location_channels.action.open_settings", defaultValue: "open settings")) {
|
||||
SystemSettings.microphone.open()
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
Text(voiceRecordingVM.state.alertMessage)
|
||||
})
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
.alert(String(localized: "content.alert.bluetooth_required.title", defaultValue: "bluetooth required"), isPresented: $appChromeModel.showBluetoothAlert) {
|
||||
Button(String(localized: "content.alert.bluetooth_required.settings", defaultValue: "settings")) {
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(appChromeModel.bluetoothAlertMessage)
|
||||
}
|
||||
@@ -267,6 +269,8 @@ struct ContentView: View {
|
||||
ContentHeaderView(
|
||||
showSidebar: $showSidebar,
|
||||
showVerifySheet: $showVerifySheet,
|
||||
showLocationNotes: $showLocationNotes,
|
||||
notesGeohash: $notesGeohash,
|
||||
isNicknameFieldFocused: $isNicknameFieldFocused,
|
||||
headerHeight: headerHeight,
|
||||
headerPeerIconSize: headerPeerIconSize,
|
||||
|
||||
@@ -30,7 +30,7 @@ struct FingerprintView: View {
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
format: String(localized: "fingerprint.message.verify_hint", defaultValue: "compare these fingerprints with %@ using a secure channel.", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
@@ -40,13 +40,13 @@ struct FingerprintView: View {
|
||||
static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched"
|
||||
static func vouchedBy(_ count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.vouched_by", comment: "How many people the user verified have vouched for this peer"),
|
||||
format: String(localized: "fingerprint.message.vouched_by", defaultValue: "vouched for by %#@people@ you verified", comment: "How many people the user verified have vouched for this peer"),
|
||||
locale: .current,
|
||||
count
|
||||
)
|
||||
}
|
||||
static func unknownPeer() -> String {
|
||||
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ struct GeohashPeopleList: View {
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", defaultValue: "teleported from elsewhere", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", defaultValue: "in this area", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", defaultValue: "you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -10,12 +10,12 @@ struct GroupChatList: View {
|
||||
let onTapGroup: (PeerID) -> Void
|
||||
|
||||
private enum Strings {
|
||||
static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list")
|
||||
static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat")
|
||||
static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count")
|
||||
static let header = String(localized: "groups.section.header", defaultValue: "groups", comment: "Section header above the private groups list")
|
||||
static let creator = String(localized: "groups.state.creator", defaultValue: "Creator", comment: "State label for a group the user created")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", defaultValue: "Opens the group chat", comment: "Accessibility hint on a group row explaining activation opens the group chat")
|
||||
static let memberCountFormat = String(localized: "groups.member_count %@", defaultValue: "(%@)", comment: "Member count shown next to a group name; placeholder is the count")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -32,13 +32,13 @@ struct LocationChannelsSheet: View {
|
||||
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", defaultValue: "invalid geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", defaultValue: "switches to this channel", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", defaultValue: "bookmark channel", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", defaultValue: "remove bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
let label = String(localized: "location_channels.mesh_label", defaultValue: "mesh", comment: "Label for the mesh channel row")
|
||||
return rowTitle(label: label, count: count)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
format: String(localized: "location_channels.subtitle_prefix", defaultValue: "#%1$@ • %2$@", comment: "Subtitle prefix showing geohash and coverage"),
|
||||
locale: .current,
|
||||
geohash, coverage
|
||||
)
|
||||
@@ -82,7 +82,7 @@ struct LocationChannelsSheet: View {
|
||||
static func subtitle(prefix: String, name: String?) -> String {
|
||||
guard let name, !name.isEmpty else { return prefix }
|
||||
return String(
|
||||
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||
format: String(localized: "location_channels.subtitle_with_name", defaultValue: "%1$@ • %2$@", comment: "Subtitle combining prefix and resolved location name"),
|
||||
locale: .current,
|
||||
prefix, name
|
||||
)
|
||||
@@ -90,7 +90,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private static func rowTitle(label: String, count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||
format: String(localized: "location_channels.row_title", defaultValue: "%1$@ [%2$#@people_count@]", comment: "List row title with participant count"),
|
||||
locale: .current,
|
||||
label, count
|
||||
)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LocationNotesView: View {
|
||||
@StateObject private var manager: LocationNotesManager
|
||||
let geohash: String
|
||||
let senderNickname: String
|
||||
let onNotesCountChanged: ((Int) -> Void)?
|
||||
|
||||
@ThemedPalette private var palette
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
|
||||
init(
|
||||
geohash: String,
|
||||
senderNickname: String,
|
||||
onNotesCountChanged: ((Int) -> Void)? = nil,
|
||||
manager: LocationNotesManager? = nil
|
||||
) {
|
||||
let gh = geohash.lowercased()
|
||||
self.geohash = gh
|
||||
self.senderNickname = senderNickname
|
||||
self.onNotesCountChanged = onNotesCountChanged
|
||||
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color { palette.background }
|
||||
private var accentGreen: Color { palette.accent }
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
private enum Strings {
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
notesContent
|
||||
}
|
||||
}
|
||||
.themedSurface()
|
||||
inputSection
|
||||
}
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
.themedSheetBackground()
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
manager.setGeohash(newValue)
|
||||
}
|
||||
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||
.onChange(of: manager.notes.count) { newValue in
|
||||
onNotesCountChanged?(newValue)
|
||||
}
|
||||
#else
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
ScrollView {
|
||||
notesContent
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
inputSection
|
||||
}
|
||||
.themedSurface()
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
#else
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
.themedSheetBackground()
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
manager.setGeohash(newValue)
|
||||
}
|
||||
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||
.onChange(of: manager.notes.count) { newValue in
|
||||
onNotesCountChanged?(newValue)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
SheetCloseButton { dismiss() }
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
let count = manager.notes.count
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(headerTitle(for: count))
|
||||
.bitchatFont(size: 18)
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
|
||||
Text(building)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(accentGreen)
|
||||
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
|
||||
Text(block)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(accentGreen)
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private func headerTitle(for count: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.header", defaultValue: "#%1$@ • %2$#@note_count@", 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
|
||||
}()
|
||||
}
|
||||
@@ -25,20 +25,20 @@ struct BlockRevealImageView: View {
|
||||
@State private var loadFailed = false
|
||||
|
||||
private enum Strings {
|
||||
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
|
||||
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
|
||||
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
|
||||
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
|
||||
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
|
||||
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
|
||||
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
|
||||
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
|
||||
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
|
||||
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
|
||||
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
||||
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
|
||||
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
|
||||
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
static let tapToReveal = String(localized: "media.image.tap_to_reveal", defaultValue: "tap to reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
|
||||
static let open = String(localized: "media.image.action.open", defaultValue: "open image", comment: "Context menu action that opens an image full screen")
|
||||
static let reveal = String(localized: "media.image.action.reveal", defaultValue: "reveal image", comment: "Context menu action that reveals a blurred image")
|
||||
static let hide = String(localized: "media.image.action.hide", defaultValue: "hide image", comment: "Context menu action that re-blurs a revealed image")
|
||||
static let delete = String(localized: "media.image.action.delete", defaultValue: "delete image", comment: "Context menu action that deletes a received image")
|
||||
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", defaultValue: "delete this image?", comment: "Title of the confirmation dialog before deleting a received image")
|
||||
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", defaultValue: "this cannot be undone — the sender may not be in range to send it again.", comment: "Body of the confirmation dialog before deleting a received image")
|
||||
static let hiddenImage = String(localized: "media.image.accessibility.hidden", defaultValue: "hidden image", comment: "Accessibility label for a blurred incoming image")
|
||||
static let revealedImage = String(localized: "media.image.accessibility.revealed", defaultValue: "image", comment: "Accessibility label for a revealed image")
|
||||
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", defaultValue: "reveals the image", comment: "Accessibility hint for a blurred image; activating it reveals the image")
|
||||
static let openHint = String(localized: "media.image.accessibility.hint.open", defaultValue: "opens the image full screen", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
||||
static let sendingImage = String(localized: "media.image.accessibility.sending", defaultValue: "sending image", comment: "Accessibility label for an image that is still sending")
|
||||
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", defaultValue: "image unavailable", comment: "Accessibility label for an image whose file could not be loaded")
|
||||
static let cancelSend = String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -154,7 +154,7 @@ struct BlockRevealImageView: View {
|
||||
Button(Strings.delete, role: .destructive) {
|
||||
onDelete?()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(verbatim: Strings.deleteConfirmMessage)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ struct MediaMessageView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
String(localized: "content.accessibility.delivery_detail_hint", defaultValue: "tap to show delivery details", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ struct VoiceNoteView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
playback.isPlaying
|
||||
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
|
||||
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
|
||||
? String(localized: "media.voice.accessibility.pause", defaultValue: "pause voice note", comment: "Accessibility label for pausing voice note playback")
|
||||
: String(localized: "media.voice.accessibility.play", defaultValue: "play voice note", comment: "Accessibility label for playing a voice note")
|
||||
)
|
||||
.accessibilityValue(playbackLabel)
|
||||
|
||||
@@ -83,7 +83,7 @@ struct VoiceNoteView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
String(localized: "media.accessibility.cancel_send", defaultValue: "cancel sending", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +16,24 @@ struct MeshPeerList: View {
|
||||
|
||||
private enum Strings {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
|
||||
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified")
|
||||
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
|
||||
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
|
||||
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", defaultValue: "blocked in geohash", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", defaultValue: "new messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let connected = String(localized: "content.accessibility.connected_mesh", defaultValue: "connected via mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
static let reachable = String(localized: "content.accessibility.reachable_mesh", defaultValue: "reachable via mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
static let nostr = String(localized: "content.accessibility.available_nostr", defaultValue: "available via Nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
static let offline = String(localized: "mesh_peers.state.offline", defaultValue: "offline", comment: "State label for a peer that is not currently reachable")
|
||||
static let favorite = String(localized: "mesh_peers.state.favorite", defaultValue: "favorite", comment: "State label for a favorited peer")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", defaultValue: "new messages", comment: "State label for a peer with unread private messages")
|
||||
static let blocked = String(localized: "mesh_peers.state.blocked", defaultValue: "blocked", comment: "State label for a blocked peer")
|
||||
static let vouched = String(localized: "mesh_peers.state.vouched", defaultValue: "vouched", comment: "State label for a peer vouched for by someone the user verified")
|
||||
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", defaultValue: "vouched for by someone you verified", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
|
||||
static let addFavorite = String(localized: "content.accessibility.add_favorite", defaultValue: "add to favorites", comment: "Accessibility label to add a favorite")
|
||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", defaultValue: "remove from favorites", comment: "Accessibility label to remove a favorite")
|
||||
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", defaultValue: "show fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", defaultValue: "opens a private chat", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let directMessage = String(localized: "content.actions.direct_message", defaultValue: "direct message", comment: "Action that opens a private chat with the person")
|
||||
static let block = String(localized: "geohash_people.action.block", defaultValue: "block", comment: "Context menu action to block a person")
|
||||
static let unblock = String(localized: "geohash_people.action.unblock", defaultValue: "unblock", comment: "Context menu action to unblock a person")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -40,12 +40,12 @@ struct MeshTopologyView: View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("topology.title")
|
||||
Text(String(localized: "topology.title", defaultValue: "mesh topology"))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
.foregroundColor(palette.primary)
|
||||
Spacer()
|
||||
refreshButton
|
||||
Button("app_info.done") {
|
||||
Button(String(localized: "app_info.done", defaultValue: "DONE")) {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -62,7 +62,7 @@ struct MeshTopologyView: View {
|
||||
NavigationView {
|
||||
content
|
||||
.themedSheetBackground()
|
||||
.navigationTitle(Text("topology.title"))
|
||||
.navigationTitle(Text(String(localized: "topology.title", defaultValue: "mesh topology")))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -86,7 +86,7 @@ struct MeshTopologyView: View {
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("topology.refresh"))
|
||||
.accessibilityLabel(Text(String(localized: "topology.refresh", defaultValue: "refresh topology")))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -94,7 +94,7 @@ struct MeshTopologyView: View {
|
||||
VStack(spacing: 12) {
|
||||
if model.nodes.count <= 1 {
|
||||
Spacer()
|
||||
Text("topology.empty")
|
||||
Text(String(localized: "topology.empty", defaultValue: "no mesh links yet — the map fills in as peer announces arrive"))
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -109,7 +109,7 @@ struct MeshTopologyView: View {
|
||||
Text(summaryText)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.foregroundColor(palette.primary)
|
||||
Text("topology.caption")
|
||||
Text(String(localized: "topology.caption", defaultValue: "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -109,11 +109,11 @@ struct MessageListView: View {
|
||||
// mentioning the only other participant is noise, and "DM"
|
||||
// would just reopen the conversation that is already open.
|
||||
if privatePeer == nil {
|
||||
Button("content.actions.mention") {
|
||||
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
|
||||
insertMention(message.sender)
|
||||
}
|
||||
if let peerID = message.senderPeerID {
|
||||
Button("content.actions.direct_message") {
|
||||
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
|
||||
privateConversationModel.openConversation(for: peerID)
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
@@ -121,14 +121,14 @@ struct MessageListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("content.actions.hug") {
|
||||
Button(String(localized: "content.actions.hug", defaultValue: "hug")) {
|
||||
conversationUIModel.sendHug(to: message.sender)
|
||||
}
|
||||
Button("content.actions.slap") {
|
||||
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
|
||||
conversationUIModel.sendSlap(to: message.sender)
|
||||
}
|
||||
}
|
||||
Button("content.message.copy") {
|
||||
Button(String(localized: "content.message.copy", defaultValue: "copy message")) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
#else
|
||||
@@ -138,12 +138,12 @@ struct MessageListView: View {
|
||||
#endif
|
||||
}
|
||||
if isResendableFailedMessage(message) {
|
||||
Button("content.actions.resend") {
|
||||
Button(String(localized: "content.actions.resend", defaultValue: "resend")) {
|
||||
conversationUIModel.resendFailedPrivateMessage(message)
|
||||
}
|
||||
}
|
||||
if showsUserActions {
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) {
|
||||
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
|
||||
}
|
||||
}
|
||||
@@ -165,14 +165,14 @@ struct MessageListView: View {
|
||||
showClearConfirmation = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"content.clear.confirm_title",
|
||||
String(localized: "content.clear.confirm_title", defaultValue: "clear this chat?"),
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.clear.confirm_action", role: .destructive) {
|
||||
Button(String(localized: "content.clear.confirm_action", defaultValue: "clear chat"), role: .destructive) {
|
||||
conversationUIModel.clearCurrentConversation()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
@@ -190,17 +190,17 @@ struct MessageListView: View {
|
||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||
}
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", defaultValue: "actions", comment: "Fallback title for the message action sheet"),
|
||||
isPresented: $showMessageActions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
Button(String(localized: "content.actions.mention", defaultValue: "mention")) {
|
||||
if let sender = selectedMessageSender {
|
||||
insertMention(sender)
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
Button(String(localized: "content.actions.direct_message", defaultValue: "direct message")) {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
privateConversationModel.openConversation(for: peerID)
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
@@ -209,23 +209,23 @@ struct MessageListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.hug") {
|
||||
Button(String(localized: "content.actions.hug", defaultValue: "hug")) {
|
||||
if let sender = selectedMessageSender {
|
||||
conversationUIModel.sendHug(to: sender)
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.slap") {
|
||||
Button(String(localized: "content.actions.slap", defaultValue: "slap")) {
|
||||
if let sender = selectedMessageSender {
|
||||
conversationUIModel.sendSlap(to: sender)
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
Button(String(localized: "content.actions.block", defaultValue: "block"), role: .destructive) {
|
||||
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
|
||||
}
|
||||
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(String(localized: "common.cancel", defaultValue: "cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
@@ -277,18 +277,18 @@ private extension MessageListView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_intro", defaultValue: "you're on #mesh — reaches people within bluetooth range", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_waiting", defaultValue: "nobody in range yet... messages appear here", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
case .location(let channel):
|
||||
emptyStateLine(
|
||||
String(
|
||||
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
|
||||
format: String(localized: "content.empty.location_intro", defaultValue: "you're in #%@ — a public location channel over the internet", comment: "First line of an empty geohash timeline naming the channel"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", defaultValue: "tap the channel name above to switch · tap bitchat/ for help", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -368,7 +368,7 @@ private extension MessageListView {
|
||||
if unseenCount > 0 {
|
||||
Text(
|
||||
String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
@@ -389,10 +389,10 @@ private extension MessageListView {
|
||||
}
|
||||
|
||||
var jumpToLatestAccessibilityLabel: String {
|
||||
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
|
||||
let base = String(localized: "content.accessibility.jump_to_latest", defaultValue: "jump to latest messages", comment: "Accessibility label for the jump to latest messages button")
|
||||
guard unseenCount > 0 else { return base }
|
||||
let count = String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
format: String(localized: "content.jump.new_count", defaultValue: "%lld new", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
//
|
||||
// NoticesView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The unified notices sheet behind the header's pin icon: one place for
|
||||
/// everything pinned around you, with a scope toggle.
|
||||
///
|
||||
/// - geo: the current geohash's notices — mesh-synced board posts merged and
|
||||
/// deduped with Nostr kind-1 location notes, so you also see notices from
|
||||
/// people who aren't on your mesh.
|
||||
/// - mesh: the mesh-local board only (empty geohash, fully offline).
|
||||
struct NoticesView: View {
|
||||
enum Tab: Hashable {
|
||||
case geo
|
||||
case mesh
|
||||
}
|
||||
|
||||
let senderNickname: String
|
||||
@ObservedObject var board: BoardManager
|
||||
|
||||
@ThemedPalette private var palette
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@State private var tab: Tab
|
||||
@State private var draft: String = ""
|
||||
@State private var urgent = false
|
||||
@State private var expiryDays = 7
|
||||
|
||||
/// Injected notes manager for tests; live use derives one per geohash.
|
||||
private let notesManager: LocationNotesManager?
|
||||
|
||||
init(
|
||||
senderNickname: String,
|
||||
board: BoardManager,
|
||||
initialTab: Tab,
|
||||
notesManager: LocationNotesManager? = nil
|
||||
) {
|
||||
self.senderNickname = senderNickname
|
||||
self.board = board
|
||||
self.notesManager = notesManager
|
||||
_tab = State(initialValue: initialTab)
|
||||
}
|
||||
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
/// The geohash the geo tab is scoped to: the selected location channel,
|
||||
/// or the device's building geohash when chatting on mesh.
|
||||
private var geoGeohash: String? {
|
||||
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return channel.geohash
|
||||
}
|
||||
return locationChannelsModel.currentBuildingGeohash
|
||||
}
|
||||
|
||||
/// The geo scope comes from device location only when no location channel
|
||||
/// is selected; that's the case that needs the location machinery.
|
||||
private var geoTabNeedsDeviceLocation: Bool {
|
||||
if case .location = locationChannelsModel.selectedChannel { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private var activeGeohash: String? {
|
||||
switch tab {
|
||||
case .geo: return geoGeohash
|
||||
case .mesh: return ""
|
||||
}
|
||||
}
|
||||
|
||||
enum Strings {
|
||||
static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet")
|
||||
static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices")
|
||||
static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices")
|
||||
static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle")
|
||||
// The pre-merge location-notes explainer, reused so its existing
|
||||
// translations carry over.
|
||||
static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet")
|
||||
static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet")
|
||||
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
||||
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
||||
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
||||
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
||||
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
||||
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
||||
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
||||
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
||||
static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button")
|
||||
static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh")
|
||||
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
|
||||
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
|
||||
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
|
||||
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||
|
||||
static func expiryDaysOption(_ days: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
||||
locale: .current,
|
||||
days
|
||||
)
|
||||
}
|
||||
|
||||
static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
||||
let base = String(
|
||||
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
||||
locale: .current,
|
||||
author, content
|
||||
)
|
||||
return urgent ? "\(urgentBadge), \(base)" : base
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
contentSection
|
||||
if activeGeohash != nil {
|
||||
composer
|
||||
}
|
||||
}
|
||||
.themedSurface()
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||
#endif
|
||||
.themedSheetBackground()
|
||||
.onAppear { beginGeoLocationIfNeeded() }
|
||||
.onChange(of: tab) { newTab in
|
||||
if newTab == .geo {
|
||||
beginGeoLocationIfNeeded()
|
||||
} else {
|
||||
locationChannelsModel.endLiveRefresh()
|
||||
}
|
||||
}
|
||||
// Catches permission granted from the geo tab's enable button.
|
||||
.onChange(of: locationChannelsModel.permissionState) { _ in
|
||||
beginGeoLocationIfNeeded()
|
||||
}
|
||||
.onDisappear { locationChannelsModel.endLiveRefresh() }
|
||||
}
|
||||
|
||||
/// The geo tab tracks the device's building geohash while on mesh; keep
|
||||
/// location fresh only in that case (a selected location channel already
|
||||
/// fixes the scope).
|
||||
private func beginGeoLocationIfNeeded() {
|
||||
guard tab == .geo, geoTabNeedsDeviceLocation,
|
||||
locationChannelsModel.permissionState == .authorized else { return }
|
||||
locationChannelsModel.enableLocationChannels()
|
||||
locationChannelsModel.beginLiveRefresh()
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(verbatim: scopeTitle)
|
||||
.bitchatFont(size: 18)
|
||||
Spacer()
|
||||
SheetCloseButton { dismiss() }
|
||||
.accessibilityLabel(Strings.closeHint)
|
||||
}
|
||||
Picker(Strings.scopePicker, selection: $tab) {
|
||||
Text(Strings.geoTab).tag(Tab.geo)
|
||||
Text(Strings.meshTab).tag(Tab.mesh)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityLabel(Strings.scopePicker)
|
||||
Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private var scopeTitle: String {
|
||||
switch tab {
|
||||
case .mesh:
|
||||
return "\(Strings.title) @ #mesh"
|
||||
case .geo:
|
||||
if let geohash = geoGeohash {
|
||||
return "\(Strings.title) @ #\(geohash)"
|
||||
}
|
||||
return Strings.title
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
switch tab {
|
||||
case .mesh:
|
||||
NoticesList(
|
||||
items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []),
|
||||
showsSource: false,
|
||||
board: board,
|
||||
notesManager: nil
|
||||
)
|
||||
case .geo:
|
||||
if let geohash = geoGeohash {
|
||||
GeoNoticesList(geohash: geohash, board: board, manager: notesManager)
|
||||
} else {
|
||||
locationUnavailableSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var locationUnavailableSection: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(Strings.locationUnavailable)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button(Strings.enableLocation) {
|
||||
locationChannelsModel.enableAndRefresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.bitchatFont(size: 14)
|
||||
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||
.padding(.vertical, 6)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.bitchatSystem(size: 20))
|
||||
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!sendEnabled)
|
||||
.accessibilityLabel(Strings.send)
|
||||
}
|
||||
// Urgency and expiry only travel with the mesh copy — the bridged
|
||||
// Nostr note carries neither, so relay-side readers would never
|
||||
// see them. Offer the controls only where they fully apply.
|
||||
if tab == .mesh {
|
||||
HStack(spacing: 12) {
|
||||
Toggle(isOn: $urgent) {
|
||||
Text(Strings.urgentToggle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.urgentToggle)
|
||||
Spacer()
|
||||
Text(Strings.expiryLabel)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
||||
ForEach([1, 3, 7], id: \.self) { days in
|
||||
Text(Strings.expiryDaysOption(days)).tag(days)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
.accessibilityLabel(Strings.expiryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.themedSurface()
|
||||
.overlay(Divider(), alignment: .top)
|
||||
}
|
||||
|
||||
private var sendEnabled: Bool {
|
||||
let trimmed = draft.trimmed
|
||||
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
// Geo posts go to the board and are bridged to Nostr by BoardManager,
|
||||
// so mesh and internet see the same notice. They always use the
|
||||
// defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy).
|
||||
let sent = board.createPost(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
urgent: tab == .mesh && urgent,
|
||||
expiryDays: tab == .mesh ? expiryDays : 7,
|
||||
nickname: senderNickname
|
||||
)
|
||||
if sent {
|
||||
draft = ""
|
||||
urgent = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The geo tab's list: owns the Nostr notes subscription for the scope
|
||||
/// geohash and merges it with the board posts for the same geohash.
|
||||
private struct GeoNoticesList: View {
|
||||
let geohash: String
|
||||
@ObservedObject var board: BoardManager
|
||||
@StateObject private var notesManager: LocationNotesManager
|
||||
|
||||
init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) {
|
||||
let gh = geohash.lowercased()
|
||||
self.geohash = gh
|
||||
self.board = board
|
||||
_notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NoticesList(
|
||||
items: UnifiedNotices.merge(
|
||||
posts: board.posts(forGeohash: geohash),
|
||||
notes: notesManager.notes
|
||||
),
|
||||
showsSource: true,
|
||||
board: board,
|
||||
notesManager: notesManager
|
||||
)
|
||||
.onChange(of: geohash) { newValue in
|
||||
notesManager.setGeohash(newValue)
|
||||
}
|
||||
.onDisappear { notesManager.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders merged notices with per-source affordances: swipe-delete for own
|
||||
/// items and a mesh/net badge when sources mix.
|
||||
private struct NoticesList: View {
|
||||
let items: [NoticeItem]
|
||||
let showsSource: Bool
|
||||
let board: BoardManager
|
||||
let notesManager: LocationNotesManager?
|
||||
|
||||
@ThemedPalette private var palette
|
||||
|
||||
private typealias Strings = NoticesView.Strings
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if items.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
statusRows
|
||||
if showEmptyState {
|
||||
Text(Strings.emptyTitle)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.emptySubtitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
statusRows
|
||||
.listRowBackground(palette.background)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
ForEach(items) { item in
|
||||
row(item)
|
||||
.listRowBackground(palette.background)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
/// Notes may still be loading or unreachable; only claim "no notices yet"
|
||||
/// once the sources settled.
|
||||
private var showEmptyState: Bool {
|
||||
guard let notesManager else { return true }
|
||||
return notesManager.initialLoadComplete && notesManager.state != .loading
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusRows: some View {
|
||||
if let notesManager {
|
||||
if notesManager.state == .loading && !notesManager.initialLoadComplete {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNotes)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
} else if notesManager.state == .noRelays {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Strings.noRelaysNearby)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.relaysRetryHint)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.retry) { notesManager.refresh() }
|
||||
.bitchatFont(size: 12)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
} else if let error = notesManager.errorMessage {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.bitchatFont(size: 12)
|
||||
Text(error)
|
||||
.bitchatFont(size: 12)
|
||||
Spacer()
|
||||
}
|
||||
Button(Strings.dismissError) { notesManager.clearError() }
|
||||
.bitchatFont(size: 12)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func canDelete(_ item: NoticeItem) -> Bool {
|
||||
switch item.source {
|
||||
case .board(let post):
|
||||
return board.isOwnPost(post)
|
||||
case .nostr(let note):
|
||||
return notesManager?.isOwnNote(note) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ item: NoticeItem) {
|
||||
switch item.source {
|
||||
case .board(let post):
|
||||
// Tombstones the board post and retracts the bridged Nostr copy.
|
||||
board.deletePost(post)
|
||||
case .nostr(let note):
|
||||
notesManager?.delete(note: note)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ item: NoticeItem) -> some View {
|
||||
let isOwn = canDelete(item)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
if item.isUrgent {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.bitchatSystem(size: 11))
|
||||
.foregroundColor(palette.alertRed)
|
||||
Text(Strings.urgentBadge)
|
||||
.bitchatFont(size: 11, weight: .semibold)
|
||||
.foregroundColor(palette.alertRed)
|
||||
}
|
||||
Text(verbatim: "@\(item.author)")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
Text(Self.timestampText(for: item.createdAt))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
if showsSource {
|
||||
sourceBadge(item)
|
||||
}
|
||||
if isOwn {
|
||||
Button {
|
||||
delete(item)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.deleteAction)
|
||||
}
|
||||
}
|
||||
Text(item.content)
|
||||
.bitchatFont(size: 14)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent))
|
||||
.accessibilityActions {
|
||||
if isOwn {
|
||||
Button(Strings.deleteAction) { delete(item) }
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if isOwn {
|
||||
Button(role: .destructive) {
|
||||
delete(item)
|
||||
} label: {
|
||||
Label(Strings.deleteAction, systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceBadge(_ item: NoticeItem) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
|
||||
.bitchatFont(size: 10)
|
||||
}
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
.accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Formatting
|
||||
|
||||
private static func timestampText(for date: Date) -> String {
|
||||
let now = Date()
|
||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||
let rel = relativeFormatter.string(from: date, to: now) ?? ""
|
||||
return rel.isEmpty ? "" : "\(rel) ago"
|
||||
}
|
||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||
return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
|
||||
}
|
||||
|
||||
private static let relativeFormatter: DateComponentsFormatter = {
|
||||
let f = DateComponentsFormatter()
|
||||
f.allowedUnits = [.day, .hour, .minute]
|
||||
f.maximumUnitCount = 1
|
||||
f.unitsStyle = .abbreviated
|
||||
f.collapsesLargestUnit = true
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absDateYearFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.setLocalizedDateFormatFromTemplate("MMM d, y")
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -18,7 +18,7 @@ struct MyQRView: View {
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
|
||||
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", defaultValue: "verification QR code", comment: "Accessibility label describing the verification QR code")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -124,13 +124,13 @@ struct QRScanView: View {
|
||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||
static func requested(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
||||
format: String(localized: "verification.scan.status.requested", defaultValue: "verification requested for %@", comment: "Status text when verification is requested for a nickname"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
|
||||
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
|
||||
static let notFound = String(localized: "verification.scan.status.no_peer", defaultValue: "could not find matching peer", comment: "Status when no matching peer is found for a verification request")
|
||||
static let invalid = String(localized: "verification.scan.status.invalid", defaultValue: "invalid or expired QR payload", comment: "Status when a scanned QR payload is invalid")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -296,7 +296,7 @@ struct VerificationSheetView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("verification.sheet.title")
|
||||
Text(String(localized: "verification.sheet.title", defaultValue: "VERIFY"))
|
||||
.bitchatFont(size: 14, weight: .bold)
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("verification.scan.prompt_friend")
|
||||
Text(String(localized: "verification.scan.prompt_friend", defaultValue: "scan a friend's QR"))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -217,6 +217,51 @@ struct BLEServiceCoreTests {
|
||||
))
|
||||
}
|
||||
|
||||
// Regression: a stable/verified private chat addresses the peer by its
|
||||
// full 64-hex Noise key, but the Noise session (and capabilities/
|
||||
// connection) are keyed by the short routing ID. The Wi-Fi bulk send path
|
||||
// must normalize the ID first, or `hasEstablishedSession` misses and
|
||||
// Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer.
|
||||
@Test
|
||||
func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws {
|
||||
let ble = makeService()
|
||||
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) })
|
||||
let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key
|
||||
#expect(fullKey.noiseKey != nil)
|
||||
let shortID = fullKey.toShort() // 16-hex routing ID
|
||||
#expect(shortID != fullKey)
|
||||
|
||||
// Establish a real Noise session in the service's noise engine, keyed
|
||||
// by the short routing ID (exactly as a completed handshake would).
|
||||
let peer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let noise = ble._test_noiseService
|
||||
let m1 = try noise.initiateHandshake(with: shortID)
|
||||
let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1))
|
||||
let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2))
|
||||
_ = try peer.processHandshakeMessage(from: shortID, message: m3)
|
||||
|
||||
#expect(noise.hasEstablishedSession(with: shortID))
|
||||
// The bug in raw form: querying by the 64-hex key misses the session.
|
||||
#expect(!noise.hasEstablishedSession(with: fullKey))
|
||||
|
||||
// Register the peer as a directly-connected `.wifiBulk` neighbor.
|
||||
ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk])
|
||||
|
||||
let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
|
||||
// The send path normalizes first, so eligibility + offer resolve for
|
||||
// both the short ID and the full 64-hex Noise key.
|
||||
let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID)
|
||||
let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey)
|
||||
#expect(viaShort.hasEstablishedNoiseSession)
|
||||
#expect(viaFull.hasEstablishedNoiseSession)
|
||||
#expect(viaFull.isDirectlyConnected)
|
||||
#expect(viaFull.peerCapabilities.contains(.wifiBulk))
|
||||
#expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true))
|
||||
#expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true))
|
||||
}
|
||||
|
||||
/// Pings are unsigned, so their claimed sender is attacker-controlled.
|
||||
/// The pong budget must be keyed on the ingress link (the directly
|
||||
/// connected peer that delivered the packet): rotating forged sender IDs
|
||||
|
||||
@@ -157,6 +157,12 @@ 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)] = []
|
||||
@@ -168,12 +174,6 @@ 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,9 +51,6 @@ 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,12 +60,8 @@ 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)
|
||||
}
|
||||
@@ -139,78 +135,14 @@ struct ChatVouchCoordinatorContextTests {
|
||||
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
|
||||
#expect(context.sentVouchPayloads.isEmpty)
|
||||
|
||||
// Verified but advertises a non-empty capability set lacking .vouch:
|
||||
// nothing. (An *empty*/unknown set is race-tolerant and still sends —
|
||||
// see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
|
||||
// Verified but no .vouch capability: nothing.
|
||||
context.verifiedFingerprints.insert(peerFingerprint)
|
||||
context.capabilitiesByPeerID[peerID] = [.prekeys]
|
||||
context.capabilitiesByPeerID[peerID] = []
|
||||
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,6 +370,7 @@ 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()
|
||||
@@ -544,6 +545,7 @@ 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()
|
||||
@@ -586,30 +588,6 @@ 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
|
||||
@@ -697,6 +675,31 @@ 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 {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
/// Guards against locale gaps in the string catalogs: every translatable key
|
||||
/// must have a localization for every supported locale, so no user ever sees
|
||||
/// an English fallback (see PR #1391 review).
|
||||
struct LocalizationCoverageTests {
|
||||
private static let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // bitchatTests
|
||||
.deletingLastPathComponent() // repo root
|
||||
|
||||
private struct Catalog {
|
||||
let sourceLanguage: String
|
||||
/// key -> set of locales with a localization entry
|
||||
let coverage: [String: Set<String>]
|
||||
/// all locales appearing anywhere in the catalog
|
||||
var allLocales: Set<String> { coverage.values.reduce(into: []) { $0.formUnion($1) } }
|
||||
}
|
||||
|
||||
private static func loadCatalog(_ relativePath: String) throws -> Catalog {
|
||||
let url = repoRoot.appendingPathComponent(relativePath)
|
||||
let data = try Data(contentsOf: url)
|
||||
let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let strings = try #require(root["strings"] as? [String: Any])
|
||||
let sourceLanguage = try #require(root["sourceLanguage"] as? String)
|
||||
|
||||
var coverage: [String: Set<String>] = [:]
|
||||
for (key, value) in strings {
|
||||
guard let entry = value as? [String: Any] else { continue }
|
||||
if entry["shouldTranslate"] as? Bool == false { continue }
|
||||
let localizations = entry["localizations"] as? [String: Any] ?? [:]
|
||||
var locales: Set<String> = []
|
||||
for (locale, loc) in localizations {
|
||||
guard let loc = loc as? [String: Any] else { continue }
|
||||
// A localization counts if it has a non-empty stringUnit value
|
||||
// or uses variations/substitutions (plural forms).
|
||||
if let unit = loc["stringUnit"] as? [String: Any],
|
||||
let unitValue = unit["value"] as? String, !unitValue.isEmpty {
|
||||
locales.insert(locale)
|
||||
} else if loc["variations"] != nil || loc["substitutions"] != nil {
|
||||
locales.insert(locale)
|
||||
}
|
||||
}
|
||||
coverage[key] = locales
|
||||
}
|
||||
return Catalog(sourceLanguage: sourceLanguage, coverage: coverage)
|
||||
}
|
||||
|
||||
@Test func mainCatalogCoversAllLocalesForEveryKey() throws {
|
||||
let catalog = try Self.loadCatalog("bitchat/Localizable.xcstrings")
|
||||
let expected = catalog.allLocales
|
||||
#expect(expected.count > 1, "catalog should declare more locales than the source language")
|
||||
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
|
||||
let missing = expected.subtracting(locales).sorted()
|
||||
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func shareExtensionCatalogCoversAllLocalesForEveryKey() throws {
|
||||
let catalog = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
let expected = catalog.allLocales
|
||||
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
|
||||
let missing = expected.subtracting(locales).sorted()
|
||||
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func shareExtensionSupportsSameLocalesAsMainApp() throws {
|
||||
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
|
||||
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
let missing = main.allLocales.subtracting(shareExt.allLocales).sorted()
|
||||
#expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
@@ -40,17 +40,14 @@ struct BLEFragmentHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func ownFragmentIsTrackedForSyncButNotAssembled() {
|
||||
func ownFragmentIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2)
|
||||
|
||||
handler.handle(packet, from: localPeerID)
|
||||
|
||||
// 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.trackedPackets.isEmpty)
|
||||
#expect(recorder.appendedHeaders.isEmpty)
|
||||
#expect(recorder.reinjectedPackets.isEmpty)
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
//
|
||||
// BLERecentPeripheralCacheTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Eviction, expiry, and reconnect-target selection for the background
|
||||
// wake-on-proximity peripheral cache.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLERecentPeripheralCacheTests {
|
||||
|
||||
private let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache<String> {
|
||||
BLERecentPeripheralCache<String>(capacity: capacity, maxAge: maxAge)
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordUpsertsByPeripheralID() {
|
||||
let cache = makeCache()
|
||||
cache.record("p1", peripheralID: "A", at: base)
|
||||
cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10))
|
||||
|
||||
#expect(cache.count == 1)
|
||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false }
|
||||
#expect(targets.map(\.peripheral) == ["p1-updated"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func overCapacityEvictsStalestEntry() {
|
||||
let cache = makeCache(capacity: 2)
|
||||
cache.record("p1", peripheralID: "A", at: base)
|
||||
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
|
||||
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2))
|
||||
|
||||
#expect(cache.count == 2)
|
||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false }
|
||||
#expect(targets.map(\.peripheralID) == ["C", "B"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func refreshingAnEntryProtectsItFromEviction() {
|
||||
let cache = makeCache(capacity: 2)
|
||||
cache.record("p1", peripheralID: "A", at: base)
|
||||
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1))
|
||||
// A becomes the freshest again; adding C must evict B, not A
|
||||
cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2))
|
||||
cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3))
|
||||
|
||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false }
|
||||
#expect(targets.map(\.peripheralID) == ["C", "A"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredEntriesArePruned() {
|
||||
let cache = makeCache(maxAge: 100)
|
||||
cache.record("p1", peripheralID: "A", at: base)
|
||||
cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50))
|
||||
|
||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false }
|
||||
#expect(targets.map(\.peripheralID) == ["B"])
|
||||
#expect(cache.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func targetsAreFreshestFirstAndCappedAtLimit() {
|
||||
let cache = makeCache(capacity: 8)
|
||||
for (index, id) in ["A", "B", "C", "D"].enumerated() {
|
||||
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
|
||||
}
|
||||
|
||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false }
|
||||
#expect(targets.map(\.peripheralID) == ["D", "C"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() {
|
||||
let cache = makeCache(capacity: 8)
|
||||
for (index, id) in ["A", "B", "C"].enumerated() {
|
||||
cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index)))
|
||||
}
|
||||
|
||||
// C (freshest) is already connected; the two slots go to B and A
|
||||
let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" }
|
||||
#expect(targets.map(\.peripheralID) == ["B", "A"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func nonPositiveLimitReturnsNothing() {
|
||||
let cache = makeCache()
|
||||
cache.record("p1", peripheralID: "A", at: base)
|
||||
|
||||
#expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty)
|
||||
#expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,13 @@ struct BLESourceRouteOriginationPolicyTests {
|
||||
)
|
||||
}
|
||||
|
||||
private func route(
|
||||
private func decide(
|
||||
packet: BitchatPacket,
|
||||
isRecipientConnected: Bool = false,
|
||||
shouldAttemptRoute: Bool = true,
|
||||
computedRoute: [Data]? = nil
|
||||
) -> [Data]? {
|
||||
BLESourceRouteOriginationPolicy.route(
|
||||
) -> BLESourceRouteOriginationPolicy.Decision {
|
||||
BLESourceRouteOriginationPolicy.decide(
|
||||
for: packet,
|
||||
to: recipient,
|
||||
localPeerIDData: localPeerIDData,
|
||||
@@ -49,38 +49,38 @@ struct BLESourceRouteOriginationPolicyTests {
|
||||
}
|
||||
|
||||
@Test func routesWhenAllGatesPass() {
|
||||
#expect(route(packet: makePacket()) == [hop])
|
||||
#expect(decide(packet: makePacket()) == .route([hop]))
|
||||
}
|
||||
|
||||
@Test func relayedPacketNeverGetsRoute() {
|
||||
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
|
||||
#expect(route(packet: relayed) == nil)
|
||||
#expect(decide(packet: relayed) == .flood(.relayedNotOriginator))
|
||||
}
|
||||
|
||||
@Test func broadcastRecipientNeverGetsRoute() {
|
||||
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
|
||||
#expect(route(packet: broadcast) == nil)
|
||||
#expect(decide(packet: broadcast) == .flood(.broadcast))
|
||||
let noRecipient = makePacket(recipientID: nil)
|
||||
#expect(route(packet: noRecipient) == nil)
|
||||
#expect(decide(packet: noRecipient) == .flood(.broadcast))
|
||||
}
|
||||
|
||||
@Test func linkLocalTTLNeverGetsRoute() {
|
||||
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
|
||||
#expect(route(packet: makePacket(ttl: 0)) == nil)
|
||||
#expect(route(packet: makePacket(ttl: 1)) == nil)
|
||||
#expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom))
|
||||
#expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom))
|
||||
}
|
||||
|
||||
@Test func directlyConnectedRecipientNeverGetsRoute() {
|
||||
#expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
|
||||
#expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect))
|
||||
}
|
||||
|
||||
@Test func suppressedRecipientFallsBackToFlood() {
|
||||
#expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
|
||||
#expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed))
|
||||
}
|
||||
|
||||
@Test func missingOrEmptyRouteFallsBackToFlood() {
|
||||
var sawComputeRoute = false
|
||||
let result = BLESourceRouteOriginationPolicy.route(
|
||||
let result = BLESourceRouteOriginationPolicy.decide(
|
||||
for: makePacket(),
|
||||
to: recipient,
|
||||
localPeerIDData: localPeerIDData,
|
||||
@@ -91,8 +91,8 @@ struct BLESourceRouteOriginationPolicyTests {
|
||||
return nil
|
||||
}
|
||||
)
|
||||
#expect(result == nil)
|
||||
#expect(result == .flood(.noPath))
|
||||
#expect(sawComputeRoute)
|
||||
#expect(route(packet: makePacket(), computedRoute: []) == nil)
|
||||
#expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
//
|
||||
// BoardAlertsModelTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
struct BoardAlertsModelTests {
|
||||
|
||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
||||
private let ownKey = Data(repeating: 7, count: 32)
|
||||
|
||||
private final class Harness {
|
||||
var lines: [(content: String, geohash: String)] = []
|
||||
var pendingFlushes: [@MainActor () -> Void] = []
|
||||
|
||||
@MainActor
|
||||
func flushAll() {
|
||||
let flushes = pendingFlushes
|
||||
pendingFlushes = []
|
||||
for flush in flushes { flush() }
|
||||
}
|
||||
}
|
||||
|
||||
private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel {
|
||||
let fixedNow = now ?? baseDate
|
||||
return BoardAlertsModel(
|
||||
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
|
||||
dependencies: BoardAlertsModel.Dependencies(
|
||||
isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey },
|
||||
emitSystemLine: { content, geohash in
|
||||
harness.lines.append((content, geohash))
|
||||
},
|
||||
now: { fixedNow },
|
||||
scheduleFlush: { flush in
|
||||
harness.pendingFlushes.append(flush)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func makePost(
|
||||
content: String = "hello",
|
||||
geohash: String = "9q8yy",
|
||||
nickname: String = "alice",
|
||||
createdAt: UInt64? = nil,
|
||||
urgent: Bool = false,
|
||||
authorKey: Data = Data(repeating: 1, count: 32),
|
||||
postID: Data? = nil
|
||||
) -> BoardPostPacket {
|
||||
BoardPostPacket(
|
||||
postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorKey,
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt ?? baseMs,
|
||||
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
|
||||
flags: urgent ? BoardPostPacket.urgentFlag : 0,
|
||||
signature: Data(repeating: 2, count: 64)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ownPosts_neverBadgeOrAlert() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(urgent: true, authorKey: ownKey))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
||||
#expect(harness.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func routinePost_badgesWithoutChatLine() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(geohash: ""))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(model.unseenCount(forGeohash: "") == 1)
|
||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
||||
#expect(harness.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func urgentRecentPost_emitsLineInMatchingScope() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true))
|
||||
#expect(harness.lines.isEmpty)
|
||||
harness.flushAll()
|
||||
|
||||
#expect(harness.lines.count == 1)
|
||||
#expect(harness.lines[0].geohash == "9q8yy")
|
||||
#expect(harness.lines[0].content.contains("road closed"))
|
||||
#expect(harness.lines[0].content.contains("@alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func urgentBackfilledPost_badgesOnly() {
|
||||
let harness = Harness()
|
||||
let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120)
|
||||
let model = makeModel(harness: harness, now: arrivalTime)
|
||||
|
||||
model.handleArrival(makePost(createdAt: baseMs, urgent: true))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
|
||||
#expect(harness.lines.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func simultaneousUrgentPosts_collapseIntoOneLine() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(content: "one", urgent: true))
|
||||
model.handleArrival(makePost(content: "two", urgent: true))
|
||||
model.handleArrival(makePost(content: "three", urgent: true))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(harness.lines.count == 1)
|
||||
#expect(harness.lines[0].content.contains("3"))
|
||||
#expect(harness.pendingFlushes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func urgentPostsInDifferentScopes_alertEachScope() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true))
|
||||
model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(harness.lines.count == 2)
|
||||
#expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""])
|
||||
}
|
||||
|
||||
@Test
|
||||
func duplicateArrival_isHandledOnce() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
let id = Data(repeating: 3, count: 16)
|
||||
|
||||
model.handleArrival(makePost(urgent: true, postID: id))
|
||||
model.handleArrival(makePost(urgent: true, postID: id))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 1)
|
||||
#expect(harness.lines.count == 1)
|
||||
#expect(!harness.lines[0].content.contains("2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func markSeen_clearsOnlyVisibleScopes() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(geohash: ""))
|
||||
model.handleArrival(makePost(geohash: "9q8yy"))
|
||||
model.handleArrival(makePost(geohash: "u4pruyd"))
|
||||
|
||||
// Opening the sheet on mesh + 9q8yy must not eat the badge for the
|
||||
// never-shown u4pruyd channel.
|
||||
model.markSeen(forScopes: ["", "9q8yy"])
|
||||
|
||||
#expect(model.unseenCount(forGeohash: "") == 0)
|
||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
||||
#expect(model.unseenCount(forGeohash: "u4pruyd") == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reset_dropsPendingUrgentLinesAndBadges() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
|
||||
model.handleArrival(makePost(content: "pre-wipe secret", urgent: true))
|
||||
#expect(harness.pendingFlushes.count == 1)
|
||||
|
||||
// Panic wipe lands before the collapse flush fires.
|
||||
model.reset()
|
||||
harness.flushAll()
|
||||
|
||||
#expect(harness.lines.isEmpty)
|
||||
#expect(model.unseenCount(forGeohash: "9q8yy") == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func longUrgentContent_isTruncatedInLine() {
|
||||
let harness = Harness()
|
||||
let model = makeModel(harness: harness)
|
||||
let long = String(repeating: "a", count: 400)
|
||||
|
||||
model.handleArrival(makePost(content: long, urgent: true))
|
||||
harness.flushAll()
|
||||
|
||||
#expect(harness.lines.count == 1)
|
||||
#expect(harness.lines[0].content.count < 200)
|
||||
#expect(harness.lines[0].content.contains("…"))
|
||||
}
|
||||
}
|
||||
@@ -337,33 +337,6 @@ struct GatewayServiceTests {
|
||||
#expect(decoded.event()?.id == event.id)
|
||||
}
|
||||
|
||||
@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,7 +111,6 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
permissionProvider: { permissionSubject.value },
|
||||
mutualFavoritesProvider: { favoritesSubject.value },
|
||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
/// Covers the reachability-gate decision logic (pure debounce) and the
|
||||
/// `NetworkActivationService` wiring that suppresses Tor/relay startup when
|
||||
/// there is provably no network.
|
||||
@MainActor
|
||||
final class NetworkReachabilityGateTests: XCTestCase {
|
||||
|
||||
// MARK: - Pure debounce logic
|
||||
|
||||
func test_debounce_satisfiedStaysReachable() {
|
||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
||||
let t0 = Date()
|
||||
// An interface remains present: no change, no pending.
|
||||
XCTAssertNil(d.observe(reachable: true, at: t0))
|
||||
XCTAssertTrue(d.committed)
|
||||
XCTAssertFalse(d.hasPendingChange)
|
||||
}
|
||||
|
||||
func test_debounce_unsatisfiedSuppressesAfterInterval() {
|
||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
||||
let t0 = Date()
|
||||
// Path drops: not committed immediately (within debounce window).
|
||||
XCTAssertNil(d.observe(reachable: false, at: t0))
|
||||
XCTAssertTrue(d.committed)
|
||||
XCTAssertTrue(d.hasPendingChange)
|
||||
// Still within window.
|
||||
XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0)))
|
||||
XCTAssertTrue(d.committed)
|
||||
// Past the window: commit unreachable.
|
||||
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false)
|
||||
XCTAssertFalse(d.committed)
|
||||
XCTAssertFalse(d.hasPendingChange)
|
||||
}
|
||||
|
||||
func test_debounce_flapIsIgnored() {
|
||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
||||
let t0 = Date()
|
||||
// Drop then recover well within the window — must never commit a change.
|
||||
XCTAssertNil(d.observe(reachable: false, at: t0))
|
||||
XCTAssertTrue(d.hasPendingChange)
|
||||
XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5)))
|
||||
XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop")
|
||||
// A late flush after the original deadline is a no-op (nothing pending).
|
||||
XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0)))
|
||||
XCTAssertTrue(d.committed)
|
||||
}
|
||||
|
||||
func test_debounce_recoverAfterOutageCommitsAfterInterval() {
|
||||
var d = ReachabilityDebounce(interval: 2.5, initial: false)
|
||||
let t0 = Date()
|
||||
XCTAssertNil(d.observe(reachable: true, at: t0))
|
||||
XCTAssertTrue(d.hasPendingChange)
|
||||
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true)
|
||||
XCTAssertTrue(d.committed)
|
||||
}
|
||||
|
||||
func test_debounce_duplicateObservationsPreservePendingDeadline() {
|
||||
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
||||
let t0 = Date()
|
||||
XCTAssertNil(d.observe(reachable: false, at: t0))
|
||||
// Duplicate unsatisfied updates mid-window keep the original deadline.
|
||||
XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0)))
|
||||
XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5)
|
||||
// A duplicate arriving past the deadline commits immediately.
|
||||
XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false)
|
||||
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
||||
}
|
||||
|
||||
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
|
||||
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
|
||||
var received: [Bool] = []
|
||||
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
let start = Date()
|
||||
monitor.ingest(reachable: false)
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
// Duplicate unsatisfied update mid-window (e.g. interface detail change
|
||||
// while still offline) must not restart the debounce window.
|
||||
monitor.ingest(reachable: false)
|
||||
|
||||
let committed = await waitUntil(timeout: 2.0) { !received.isEmpty }
|
||||
XCTAssertTrue(committed)
|
||||
XCTAssertEqual(received, [false])
|
||||
// The flush must fire at the original ~1.0s deadline, not ~1.5s
|
||||
// (a full interval after the duplicate).
|
||||
XCTAssertLessThan(Date().timeIntervalSince(start), 1.4)
|
||||
}
|
||||
|
||||
// MARK: - Service gating
|
||||
|
||||
func test_start_whenUnreachable_suppressesTorAndRelays() {
|
||||
let ctx = makeService(permission: .authorized, reachable: false)
|
||||
ctx.service.start()
|
||||
|
||||
XCTAssertFalse(ctx.service.activationAllowed)
|
||||
XCTAssertFalse(ctx.service.isNetworkReachable)
|
||||
XCTAssertTrue(ctx.reachability.startCalled)
|
||||
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0)
|
||||
XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false])
|
||||
XCTAssertEqual(ctx.relayController.connectCallCount, 0)
|
||||
XCTAssertEqual(ctx.relayController.disconnectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_start_whenReachable_allowsTorAndRelays() {
|
||||
let ctx = makeService(permission: .authorized, reachable: true)
|
||||
ctx.service.start()
|
||||
|
||||
XCTAssertTrue(ctx.service.activationAllowed)
|
||||
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1)
|
||||
XCTAssertEqual(ctx.relayController.connectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_reachabilityRecovery_resumesTorAndRelays() async {
|
||||
let ctx = makeService(permission: .authorized, reachable: false)
|
||||
ctx.service.start()
|
||||
XCTAssertFalse(ctx.service.activationAllowed)
|
||||
|
||||
ctx.reachability.set(true)
|
||||
let resumed = await waitUntil { ctx.service.activationAllowed }
|
||||
XCTAssertTrue(resumed)
|
||||
XCTAssertTrue(ctx.service.isNetworkReachable)
|
||||
XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1)
|
||||
XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1)
|
||||
}
|
||||
|
||||
func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async {
|
||||
let ctx = makeService(permission: .authorized, reachable: true)
|
||||
ctx.service.start()
|
||||
XCTAssertTrue(ctx.service.activationAllowed)
|
||||
let disconnectsBefore = ctx.relayController.disconnectCallCount
|
||||
|
||||
ctx.reachability.set(false)
|
||||
let suppressed = await waitUntil { !ctx.service.activationAllowed }
|
||||
XCTAssertTrue(suppressed)
|
||||
XCTAssertFalse(ctx.service.isNetworkReachable)
|
||||
XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore)
|
||||
XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false))
|
||||
XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1)
|
||||
}
|
||||
|
||||
// MARK: - Harness
|
||||
|
||||
private func makeService(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
reachable: Bool
|
||||
) -> Context {
|
||||
let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName)!
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
|
||||
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>([])
|
||||
let reachability = ControllableReachabilityMonitor(initial: reachable)
|
||||
let torController = GateMockTorController()
|
||||
let relayController = GateMockRelayController()
|
||||
let proxyController = GateMockProxyController()
|
||||
let service = NetworkActivationService(
|
||||
storage: storage,
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
permissionProvider: { permissionSubject.value },
|
||||
mutualFavoritesProvider: { favoritesSubject.value },
|
||||
reachabilityMonitor: reachability,
|
||||
torController: torController,
|
||||
relayController: relayController,
|
||||
proxyController: proxyController,
|
||||
notificationCenter: NotificationCenter()
|
||||
)
|
||||
return Context(
|
||||
service: service,
|
||||
reachability: reachability,
|
||||
torController: torController,
|
||||
relayController: relayController
|
||||
)
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() { return true }
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct Context {
|
||||
let service: NetworkActivationService
|
||||
let reachability: ControllableReachabilityMonitor
|
||||
let torController: GateMockTorController
|
||||
let relayController: GateMockRelayController
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring {
|
||||
private let subject: CurrentValueSubject<Bool, Never>
|
||||
private(set) var startCalled = false
|
||||
|
||||
init(initial: Bool) {
|
||||
subject = CurrentValueSubject(initial)
|
||||
}
|
||||
|
||||
var isReachable: Bool { subject.value }
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||
}
|
||||
func start() { startCalled = true }
|
||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class GateMockTorController: NetworkActivationTorControlling {
|
||||
private(set) var autoStartAllowedValues: [Bool] = []
|
||||
private(set) var startIfNeededCallCount = 0
|
||||
private(set) var shutdownCompletelyCallCount = 0
|
||||
func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) }
|
||||
func startIfNeeded() { startIfNeededCallCount += 1 }
|
||||
func shutdownCompletely() { shutdownCompletelyCallCount += 1 }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class GateMockRelayController: NetworkActivationRelayControlling {
|
||||
private(set) var connectCallCount = 0
|
||||
private(set) var disconnectCallCount = 0
|
||||
func connect() { connectCallCount += 1 }
|
||||
func disconnect() { disconnectCallCount += 1 }
|
||||
}
|
||||
|
||||
private final class GateMockProxyController: NetworkActivationProxyControlling {
|
||||
private(set) var proxyModes: [Bool] = []
|
||||
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
//
|
||||
// UnifiedNoticesTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct UnifiedNoticesTests {
|
||||
|
||||
private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
|
||||
|
||||
private func makePost(
|
||||
content: String,
|
||||
nickname: String = "alice",
|
||||
createdAt: UInt64? = nil,
|
||||
urgent: Bool = false
|
||||
) -> BoardPostPacket {
|
||||
BoardPostPacket(
|
||||
postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
|
||||
geohash: "9q8yy",
|
||||
content: content,
|
||||
authorSigningKey: Data(repeating: 1, count: 32),
|
||||
authorNickname: nickname,
|
||||
createdAt: createdAt ?? baseMs,
|
||||
expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
|
||||
flags: urgent ? BoardPostPacket.urgentFlag : 0,
|
||||
signature: Data(repeating: 2, count: 64)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeNote(
|
||||
content: String,
|
||||
nickname: String? = "alice",
|
||||
createdAt: Date? = nil,
|
||||
geohash: String = "9q8yy"
|
||||
) -> LocationNotesManager.Note {
|
||||
LocationNotesManager.Note(
|
||||
id: UUID().uuidString,
|
||||
pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""),
|
||||
content: content,
|
||||
createdAt: createdAt ?? baseDate,
|
||||
nickname: nickname,
|
||||
geohash: geohash
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func merge_dropsBridgedCopyOfBoardPost() {
|
||||
let post = makePost(content: "free couch on 5th")
|
||||
let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30))
|
||||
|
||||
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
|
||||
|
||||
#expect(merged.count == 1)
|
||||
#expect(merged[0].isBoardPost)
|
||||
}
|
||||
|
||||
@Test
|
||||
func merge_keepsNoteWithSameContentOutsideWindow() {
|
||||
let post = makePost(content: "water station here")
|
||||
let oldNote = makeNote(
|
||||
content: "water station here",
|
||||
createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60)
|
||||
)
|
||||
|
||||
let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote])
|
||||
|
||||
#expect(merged.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func merge_keepsSameTextNoteFromNeighborCell() {
|
||||
// The notes subscription covers the center cell plus 8 neighbors; a
|
||||
// matching note posted to a *neighbor* is not the bridged copy.
|
||||
let post = makePost(content: "free couch on 5th")
|
||||
let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz")
|
||||
|
||||
let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote])
|
||||
|
||||
#expect(merged.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func merge_keepsNoteFromDifferentAuthor() {
|
||||
let post = makePost(content: "meetup at 6", nickname: "alice")
|
||||
let note = makeNote(content: "meetup at 6", nickname: "bob")
|
||||
|
||||
let merged = UnifiedNotices.merge(posts: [post], notes: [note])
|
||||
|
||||
#expect(merged.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func merge_sortsUrgentFirstThenNewest() {
|
||||
let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true)
|
||||
let newerPost = makePost(content: "later post", createdAt: baseMs)
|
||||
let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30))
|
||||
|
||||
let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note])
|
||||
|
||||
#expect(merged.map(\.content) == ["road closed", "a note", "later post"])
|
||||
#expect(merged[0].isUrgent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func merge_anonNicknamesMatchForDedupe() {
|
||||
// Bridged posts from an empty nickname arrive as anon notes with no
|
||||
// "n" tag; they must still dedupe against the anon board copy.
|
||||
let post = makePost(content: "hello", nickname: "")
|
||||
let bridged = makeNote(content: "hello", nickname: nil)
|
||||
|
||||
let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
|
||||
|
||||
#expect(merged.count == 1)
|
||||
#expect(merged[0].isBoardPost)
|
||||
#expect(merged[0].author == "anon")
|
||||
}
|
||||
|
||||
@Test
|
||||
func noticeItem_normalizesNoteDisplayName() {
|
||||
let note = LocationNotesManager.Note(
|
||||
id: "e1",
|
||||
pubkey: "deadbeef",
|
||||
content: "hi",
|
||||
createdAt: baseDate,
|
||||
nickname: "dave",
|
||||
geohash: "9q8yy"
|
||||
)
|
||||
|
||||
let item = NoticeItem(note: note)
|
||||
|
||||
#expect(item.author == "dave")
|
||||
#expect(!item.isBoardPost)
|
||||
#expect(!item.isUrgent)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ struct GossipSyncBoardTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.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 (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.
|
||||
// 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.
|
||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
|
||||
#expect(decoded.toMessageTypes().isEmpty)
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] {
|
||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
||||
#expect(!decoded.contains(type))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,43 +13,33 @@ struct SyncTypeFlagsTests {
|
||||
}
|
||||
|
||||
@Test func decodeDropsPhantomBits() {
|
||||
// Bits 11+ map to no message type (bit 8 = boardPost, bit 9 =
|
||||
// prekeyBundle, bit 10 = groupMessage). They must not survive decode
|
||||
// as phantom membership.
|
||||
// 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.
|
||||
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 bits 11+ are phantom.
|
||||
// Low byte = announce(0) + message(1); high byte = phantom (bits 11+,
|
||||
// which map to no known type).
|
||||
let mixed = Data([0b0000_0011, 0xF8])
|
||||
let decoded = SyncTypeFlags.decode(mixed)
|
||||
#expect(decoded?.contains(.announce) == true)
|
||||
#expect(decoded?.contains(.message) == true)
|
||||
// Only the two known bits remain; phantom high bits are gone.
|
||||
// Only the two known bits remain; phantom high byte is gone.
|
||||
#expect(decoded?.rawValue == 0b0000_0011)
|
||||
}
|
||||
|
||||
@Test func rawValueInitNormalizesPhantomBits() {
|
||||
let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF)
|
||||
// Every known type bit is set; nothing above them survives. boardPost
|
||||
// occupies bit 8, so the known set spills into a second byte.
|
||||
// Every known type bit is set; nothing above them survives. The
|
||||
// highest known bit is 10 (groupMessage), so the field serializes to
|
||||
// two bytes.
|
||||
#expect(flags.contains(.announce))
|
||||
#expect(flags.contains(.fileTransfer))
|
||||
#expect(flags.contains(.board))
|
||||
let data = flags.toData()
|
||||
#expect(data?.count == 2)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Combine
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@@ -40,7 +39,6 @@ private struct SmokeFeatureModels {
|
||||
let verificationModel: VerificationModel
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let peerListModel: PeerListModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -82,14 +80,6 @@ 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,
|
||||
@@ -98,8 +88,7 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
||||
privateConversationModel: privateConversationModel,
|
||||
verificationModel: verificationModel,
|
||||
conversationUIModel: conversationUIModel,
|
||||
peerListModel: peerListModel,
|
||||
boardAlertsModel: boardAlertsModel
|
||||
peerListModel: peerListModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +106,6 @@ private func installSmokeEnvironment<V: View>(
|
||||
.environmentObject(featureModels.verificationModel)
|
||||
.environmentObject(featureModels.conversationUIModel)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
.environmentObject(featureModels.boardAlertsModel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -407,17 +395,9 @@ struct ViewSmokeTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func noticesView_rendersNoRelayAndLoadedStates() throws {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
||||
let (viewModel, _, _) = 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",
|
||||
@@ -460,28 +440,18 @@ struct ViewSmokeTests {
|
||||
eose?()
|
||||
|
||||
_ = mount(
|
||||
NoticesView(
|
||||
LocationNotesView(
|
||||
geohash: "u4pruydq",
|
||||
senderNickname: viewModel.nickname,
|
||||
board: board,
|
||||
initialTab: .geo,
|
||||
notesManager: noRelayManager
|
||||
manager: noRelayManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
_ = mount(
|
||||
NoticesView(
|
||||
LocationNotesView(
|
||||
geohash: "u4pruydq",
|
||||
senderNickname: viewModel.nickname,
|
||||
board: board,
|
||||
initialTab: .geo,
|
||||
notesManager: loadedManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
_ = mount(
|
||||
NoticesView(
|
||||
senderNickname: viewModel.nickname,
|
||||
board: board,
|
||||
initialTab: .mesh
|
||||
manager: loadedManager
|
||||
)
|
||||
.environmentObject(featureModels.locationChannelsModel)
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user