Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021af3a22d | ||
|
|
8c9c1cc6ac | ||
|
|
55d51ba084 | ||
|
|
dc99d641c8 | ||
|
|
020de96519 | ||
|
|
d499c3e415 | ||
|
|
4baeaab717 | ||
|
|
74b8a47d02 | ||
|
|
ef848857b7 | ||
|
|
d5cf64f99e | ||
|
|
35fb9fdd42 | ||
|
|
3e9230229d | ||
|
|
78a291ab77 | ||
|
|
eacd8f0750 | ||
|
|
6886035632 | ||
|
|
e642f696cb | ||
|
|
3f677776bb | ||
|
|
5dffb04912 | ||
|
|
81c45e9649 | ||
|
|
963d3a089f | ||
|
|
f79c2e3e9f | ||
|
|
0c3136282e | ||
|
|
b043324035 | ||
|
|
bbe5e1ef4e | ||
|
|
28ffc53f3f | ||
|
|
8415c52913 | ||
|
|
a0c517c018 | ||
|
|
81a10f73f0 | ||
|
|
87910541ef | ||
|
|
f9032cf2b9 | ||
|
|
d4f0c49787 | ||
|
|
2360140760 | ||
|
|
70229f0be1 | ||
|
|
cee2bcd535 | ||
|
|
3c610a83cd | ||
|
|
60be88a4f5 | ||
|
|
ede6368296 | ||
|
|
276cde44e7 | ||
|
|
201dbac49a | ||
|
|
5fdc15d5af | ||
|
|
c2a5668569 |
@@ -0,0 +1,30 @@
|
|||||||
|
name: Dead Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
periphery:
|
||||||
|
name: Periphery scan
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||||
|
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Periphery
|
||||||
|
# homebrew-core formula; the peripheryapp tap lags years behind.
|
||||||
|
run: brew install periphery
|
||||||
|
|
||||||
|
- name: Scan for dead code
|
||||||
|
# Config comes from .periphery.yml; known findings (mostly iOS-only
|
||||||
|
# code invisible to a macOS scan) are suppressed by the committed
|
||||||
|
# baseline. --strict fails the step when NEW dead code appears.
|
||||||
|
run: periphery scan --strict --disable-update-check
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
|
||||||
|
#
|
||||||
|
# CI runs the macOS scheme only (an iOS scan needs a device destination and
|
||||||
|
# doubles the build time). macOS-only scans falsely flag iOS-only code —
|
||||||
|
# state restoration, screenshot handlers, background BLE sampling — so those
|
||||||
|
# findings live in .periphery.baseline.json rather than being "fixed".
|
||||||
|
# When auditing by hand, scan BOTH schemes and intersect:
|
||||||
|
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
|
||||||
|
project: bitchat.xcodeproj
|
||||||
|
schemes:
|
||||||
|
- bitchat (macOS)
|
||||||
|
retain_swift_ui_previews: true
|
||||||
|
relative_results: true
|
||||||
|
baseline: .periphery.baseline.json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.4
|
MARKETING_VERSION = 1.7.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -36,34 +36,12 @@ enum AppEvent: Sendable, Equatable {
|
|||||||
actor AppEventStream {
|
actor AppEventStream {
|
||||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||||
|
|
||||||
func stream() -> AsyncStream<AppEvent> {
|
|
||||||
let id = UUID()
|
|
||||||
return AsyncStream { continuation in
|
|
||||||
continuations[id] = continuation
|
|
||||||
continuation.onTermination = { [id] _ in
|
|
||||||
Task {
|
|
||||||
await self.removeContinuation(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func emit(_ event: AppEvent) {
|
func emit(_ event: AppEvent) {
|
||||||
for continuation in continuations.values {
|
for continuation in continuations.values {
|
||||||
continuation.yield(event)
|
continuation.yield(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func finish() {
|
|
||||||
for continuation in continuations.values {
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
continuations.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeContinuation(_ id: UUID) {
|
|
||||||
continuations.removeValue(forKey: id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identity key for a direct conversation. Equality and hashing use the
|
/// Identity key for a direct conversation. Equality and hashing use the
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ final class AppChromeModel: ObservableObject {
|
|||||||
@Published var showingFingerprintFor: PeerID?
|
@Published var showingFingerprintFor: PeerID?
|
||||||
@Published var isAppInfoPresented = false
|
@Published var isAppInfoPresented = false
|
||||||
@Published var isLocationChannelsSheetPresented = false
|
@Published var isLocationChannelsSheetPresented = false
|
||||||
|
@Published var isNoticesSheetPresented = false
|
||||||
|
/// When the sheet is opened for "notes left here" (empty mesh timeline),
|
||||||
|
/// it should land on the geo tab instead of the channel-derived default.
|
||||||
|
@Published var noticesSheetPrefersGeoTab = false
|
||||||
@Published var showBluetoothAlert = false
|
@Published var showBluetoothAlert = false
|
||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
@@ -18,6 +22,9 @@ final class AppChromeModel: ObservableObject {
|
|||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
|
|
||||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.nickname = chatViewModel.nickname
|
self.nickname = chatViewModel.nickname
|
||||||
@@ -59,6 +66,33 @@ final class AppChromeModel: ObservableObject {
|
|||||||
isAppInfoPresented = true
|
isAppInfoPresented = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func presentNotices(geoTab: Bool = false) {
|
||||||
|
noticesSheetPrefersGeoTab = geoTab
|
||||||
|
isNoticesSheetPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the mesh topology map model from the transport's gossiped
|
||||||
|
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||||
|
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||||
|
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||||
|
let mesh = chatViewModel.meshService
|
||||||
|
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||||
|
let nicknames = mesh.getPeerNicknames()
|
||||||
|
|
||||||
|
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||||
|
let isSelf = peerID == snapshot.localPeerID
|
||||||
|
let label: String
|
||||||
|
if isSelf {
|
||||||
|
label = chatViewModel.nickname
|
||||||
|
} else {
|
||||||
|
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
||||||
|
}
|
||||||
|
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
||||||
|
}
|
||||||
|
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
||||||
|
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
||||||
|
}
|
||||||
|
|
||||||
func triggerScreenshotPrivacyWarning() {
|
func triggerScreenshotPrivacyWarning() {
|
||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||||
/// and `ChatViewModel` observe and mutate it through its intent API.
|
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||||
let conversations: ConversationStore
|
let conversations: ConversationStore
|
||||||
let peerIdentityStore: PeerIdentityStore
|
|
||||||
let locationPresenceStore: LocationPresenceStore
|
|
||||||
let publicChatModel: PublicChatModel
|
let publicChatModel: PublicChatModel
|
||||||
let privateInboxModel: PrivateInboxModel
|
let privateInboxModel: PrivateInboxModel
|
||||||
let privateConversationModel: PrivateConversationModel
|
let privateConversationModel: PrivateConversationModel
|
||||||
@@ -28,6 +26,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationChannelsModel: LocationChannelsModel
|
let locationChannelsModel: LocationChannelsModel
|
||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let appChromeModel: AppChromeModel
|
let appChromeModel: AppChromeModel
|
||||||
|
let boardAlertsModel: BoardAlertsModel
|
||||||
|
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -41,7 +40,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
@@ -50,8 +49,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationPresenceStore = LocationPresenceStore()
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
let locationManager = LocationChannelManager.shared
|
let locationManager = LocationChannelManager.shared
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
self.peerIdentityStore = peerIdentityStore
|
|
||||||
self.locationPresenceStore = locationPresenceStore
|
|
||||||
self.chatViewModel = ChatViewModel(
|
self.chatViewModel = ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
@@ -91,6 +88,24 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
privateInboxModel: self.privateInboxModel
|
privateInboxModel: self.privateInboxModel
|
||||||
)
|
)
|
||||||
|
let chatViewModel = self.chatViewModel
|
||||||
|
self.boardAlertsModel = BoardAlertsModel(
|
||||||
|
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
||||||
|
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
||||||
|
dependencies: BoardAlertsModel.Dependencies(
|
||||||
|
isOwnPost: { post in
|
||||||
|
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
||||||
|
return !key.isEmpty && key == post.authorSigningKey
|
||||||
|
},
|
||||||
|
emitSystemLine: { content, geohash in
|
||||||
|
if geohash.isEmpty {
|
||||||
|
chatViewModel.addMeshOnlySystemMessage(content)
|
||||||
|
} else {
|
||||||
|
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
@@ -202,7 +217,16 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel.applicationWillTerminate()
|
chatViewModel.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
func handleNotificationResponse(
|
||||||
|
identifier: String,
|
||||||
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) {
|
||||||
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
|
chatViewModel.sendMeshWave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
record(.notificationOpened(peerID: peerID))
|
record(.notificationOpened(peerID: peerID))
|
||||||
chatViewModel.startPrivateChat(with: peerID)
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
|||||||
@@ -796,16 +796,6 @@ extension ConversationStore {
|
|||||||
return messageIDs
|
return messageIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every direct conversation (panic clear).
|
|
||||||
func removeAllDirectConversations() {
|
|
||||||
let directIDs = conversationIDs.filter { id in
|
|
||||||
if case .direct = id { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for id in directIDs {
|
|
||||||
removeConversation(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Diagnostics support
|
// MARK: - Diagnostics support
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
@Published private(set) var currentNickname: String
|
@Published private(set) var currentNickname: String
|
||||||
@Published private(set) var isBatchingPublic = false
|
@Published private(set) var isBatchingPublic = false
|
||||||
@Published private(set) var canSendMediaInCurrentContext = true
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
/// Who is talking live in the public mesh channel right now (floor
|
||||||
|
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||||
|
@Published private(set) var activeLiveVoiceTalker: String?
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private let privateConversationModel: PrivateConversationModel
|
private let privateConversationModel: PrivateConversationModel
|
||||||
@@ -150,6 +153,17 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
chatViewModel.sendVoiceNote(at: url)
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||||
|
/// peer can hear it now, classic voice note otherwise.
|
||||||
|
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||||
|
chatViewModel.makeVoiceCaptureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this message is a live voice burst still streaming in.
|
||||||
|
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
|
||||||
|
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
func cancelMediaSend(messageID: String) {
|
func cancelMediaSend(messageID: String) {
|
||||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -175,6 +189,10 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$isBatchingPublic)
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
|
chatViewModel.$activePublicVoiceTalker
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$activeLiveVoiceTalker)
|
||||||
|
|
||||||
conversations.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
@@ -193,7 +211,9 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
|
|
||||||
private func refreshComputedState() {
|
private func refreshComputedState() {
|
||||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
// Media transfer is not wired for groups in v1; keep it off so the
|
||||||
|
// composer can't strand a media placeholder that never sends.
|
||||||
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -12,20 +11,25 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
@Published private(set) var bookmarkNames: [String: String]
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
@Published private(set) var userTorEnabled: Bool
|
@Published private(set) var userTorEnabled: Bool
|
||||||
|
@Published private(set) var gatewayEnabled: Bool
|
||||||
|
|
||||||
private let manager: LocationChannelManager
|
private let manager: LocationChannelManager
|
||||||
private let network: NetworkActivationService
|
private let network: NetworkActivationService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private let gateway: GatewayService
|
||||||
|
|
||||||
init(
|
init(
|
||||||
manager: LocationChannelManager? = nil,
|
manager: LocationChannelManager? = nil,
|
||||||
network: NetworkActivationService? = nil
|
network: NetworkActivationService? = nil,
|
||||||
|
gateway: GatewayService? = nil
|
||||||
) {
|
) {
|
||||||
let manager = manager ?? .shared
|
let manager = manager ?? .shared
|
||||||
let network = network ?? .shared
|
let network = network ?? .shared
|
||||||
|
let gateway = gateway ?? .shared
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.network = network
|
self.network = network
|
||||||
|
self.gateway = gateway
|
||||||
|
self.gatewayEnabled = gateway.isEnabled
|
||||||
self.permissionState = manager.permissionState
|
self.permissionState = manager.permissionState
|
||||||
self.availableChannels = manager.availableChannels
|
self.availableChannels = manager.availableChannels
|
||||||
self.selectedChannel = manager.selectedChannel
|
self.selectedChannel = manager.selectedChannel
|
||||||
@@ -160,6 +164,10 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.$userTorEnabled
|
network.$userTorEnabled
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$userTorEnabled)
|
.assign(to: &$userTorEnabled)
|
||||||
|
|
||||||
|
gateway.$isEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$gatewayEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// NearbyNotesCounter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Counts unexpired location notes left at the user's current building-level
|
||||||
|
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
|
||||||
|
// subscribes while a view holds it active, and only when location notes are
|
||||||
|
// enabled and location permission is already granted (it never prompts).
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NearbyNotesCounter: ObservableObject {
|
||||||
|
static let shared = NearbyNotesCounter()
|
||||||
|
|
||||||
|
@Published private(set) var noteCount = 0
|
||||||
|
|
||||||
|
private var manager: LocationNotesManager?
|
||||||
|
private var managerCancellable: AnyCancellable?
|
||||||
|
private var channelsCancellable: AnyCancellable?
|
||||||
|
private var settingCancellable: AnyCancellable?
|
||||||
|
private var activeHolders = 0
|
||||||
|
private let locationManager: LocationChannelManager
|
||||||
|
|
||||||
|
init(locationManager: LocationChannelManager = .shared) {
|
||||||
|
self.locationManager = locationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begins (or keeps) the notes subscription for the current building
|
||||||
|
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
|
||||||
|
/// hold it.
|
||||||
|
func activate() {
|
||||||
|
activeHolders += 1
|
||||||
|
guard activeHolders == 1 else { return }
|
||||||
|
channelsCancellable = locationManager.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
|
// The app-info kill switch must take effect immediately, not on the
|
||||||
|
// next location change or remount.
|
||||||
|
settingCancellable = NotificationCenter.default
|
||||||
|
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
|
retarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
activeHolders = max(0, activeHolders - 1)
|
||||||
|
guard activeHolders == 0 else { return }
|
||||||
|
channelsCancellable = nil
|
||||||
|
settingCancellable = nil
|
||||||
|
managerCancellable = nil
|
||||||
|
manager?.cancel()
|
||||||
|
manager = nil
|
||||||
|
noteCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retarget() {
|
||||||
|
guard activeHolders > 0,
|
||||||
|
LocationNotesSettings.enabled,
|
||||||
|
locationManager.permissionState == .authorized,
|
||||||
|
let geohash = locationManager.availableChannels
|
||||||
|
.first(where: { $0.level == .building })?.geohash
|
||||||
|
else {
|
||||||
|
managerCancellable = nil
|
||||||
|
manager?.cancel()
|
||||||
|
manager = nil
|
||||||
|
noteCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let manager {
|
||||||
|
manager.setGeohash(geohash)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fresh = LocationNotesManager(geohash: geohash)
|
||||||
|
manager = fresh
|
||||||
|
managerCancellable = fresh.$notes
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] notes in
|
||||||
|
let now = Date()
|
||||||
|
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,6 @@ final class PeerIdentityStore: ObservableObject {
|
|||||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
|
||||||
stablePeerIDsByShortID = mappings
|
|
||||||
}
|
|
||||||
|
|
||||||
func fingerprint(for peerID: PeerID) -> String? {
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
peerFingerprintsByPeerID[peerID]
|
peerFingerprintsByPeerID[peerID]
|
||||||
}
|
}
|
||||||
@@ -94,10 +90,6 @@ final class PeerIdentityStore: ObservableObject {
|
|||||||
invalidateEncryptionCache(for: peerID)
|
invalidateEncryptionCache(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
|
||||||
encryptionStatuses = statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||||
verifiedFingerprints = fingerprints
|
verifiedFingerprints = fingerprints
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable {
|
|||||||
let isMutualFavorite: Bool
|
let isMutualFavorite: Bool
|
||||||
let encryptionStatus: EncryptionStatus
|
let encryptionStatus: EncryptionStatus
|
||||||
let showsVerifiedBadgeWhenOffline: Bool
|
let showsVerifiedBadgeWhenOffline: Bool
|
||||||
|
/// Vouched-for by someone I verified, without an explicit verification of
|
||||||
|
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
||||||
|
let showsVouchedBadge: Bool
|
||||||
|
|
||||||
var id: String { peerID.id }
|
var id: String { peerID.id }
|
||||||
}
|
}
|
||||||
@@ -26,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
|||||||
let isBlocked: Bool
|
let isBlocked: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GroupChatRow: Identifiable, Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let name: String
|
||||||
|
let memberCount: Int
|
||||||
|
let isCreator: Bool
|
||||||
|
let hasUnread: Bool
|
||||||
|
|
||||||
|
var id: String { peerID.id }
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PeerListModel: ObservableObject {
|
final class PeerListModel: ObservableObject {
|
||||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
|
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||||
@Published private(set) var reachableMeshPeerCount = 0
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
@Published private(set) var connectedMeshPeerCount = 0
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
@Published private(set) var visibleGeohashPeerCount = 0
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
@@ -129,6 +143,13 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.groupStore.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
peerIdentityStore.$encryptionStatuses
|
peerIdentityStore.$encryptionStatuses
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject {
|
|||||||
let myPeerID = chatViewModel.meshService.myPeerID
|
let myPeerID = chatViewModel.meshService.myPeerID
|
||||||
let meshRows = allPeers.map { peer in
|
let meshRows = allPeers.map { peer in
|
||||||
let isMe = peer.peerID == myPeerID
|
let isMe = peer.peerID == myPeerID
|
||||||
let verifiedBadge: Bool
|
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||||
if !isMe && !peer.isConnected,
|
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
// Vouched is subordinate to verified: never show both seals.
|
||||||
} else {
|
let vouchedBadge = !isVerifiedFingerprint
|
||||||
verifiedBadge = false
|
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||||
}
|
|
||||||
|
|
||||||
return MeshPeerRow(
|
return MeshPeerRow(
|
||||||
peerID: peer.peerID,
|
peerID: peer.peerID,
|
||||||
@@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject {
|
|||||||
isReachable: peer.isReachable,
|
isReachable: peer.isReachable,
|
||||||
isMutualFavorite: peer.isMutualFavorite,
|
isMutualFavorite: peer.isMutualFavorite,
|
||||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||||
|
showsVouchedBadge: vouchedBadge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,22 +238,40 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let geohashPeople = buildGeohashPeople()
|
let geohashPeople = buildGeohashPeople()
|
||||||
|
let groupRows = buildGroupRows()
|
||||||
|
|
||||||
self.meshRows = meshRows
|
self.meshRows = meshRows
|
||||||
reachableMeshPeerCount = meshCounts.reachable
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
connectedMeshPeerCount = meshCounts.connected
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
self.geohashPeople = geohashPeople
|
self.geohashPeople = geohashPeople
|
||||||
visibleGeohashPeerCount = geohashPeople.count
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
|
self.groupRows = groupRows
|
||||||
renderID = (
|
renderID = (
|
||||||
meshRows.map {
|
meshRows.map {
|
||||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
} +
|
} +
|
||||||
geohashPeople.map {
|
geohashPeople.map {
|
||||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
|
} +
|
||||||
|
groupRows.map {
|
||||||
|
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||||
}
|
}
|
||||||
).joined(separator: "|")
|
).joined(separator: "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func buildGroupRows() -> [GroupChatRow] {
|
||||||
|
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||||
|
return chatViewModel.groupStore.groups.map { group in
|
||||||
|
GroupChatRow(
|
||||||
|
peerID: group.peerID,
|
||||||
|
name: group.name,
|
||||||
|
memberCount: group.members.count,
|
||||||
|
isCreator: group.creatorFingerprint == myFingerprint,
|
||||||
|
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
let myHex = currentGeohashIdentityHex()
|
let myHex = currentGeohashIdentityHex()
|
||||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
|||||||
let encryptionStatus: EncryptionStatus?
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
var supportsFavoriteToggle: Bool {
|
var supportsFavoriteToggle: Bool {
|
||||||
!conversationPeerID.isGeoDM
|
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group chats have no single peer identity behind the header: no
|
||||||
|
/// fingerprint screen, no per-peer encryption badge.
|
||||||
|
var isGroupConversation: Bool {
|
||||||
|
conversationPeerID.isGroup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +212,13 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.groupStore.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||||
|
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||||
|
// the header; availability reads as mesh since group traffic floods
|
||||||
|
// the local mesh, and the per-peer encryption badge does not apply.
|
||||||
|
if conversationPeerID.isGroup {
|
||||||
|
let displayName: String
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
return PrivateConversationHeaderState(
|
||||||
|
conversationPeerID: conversationPeerID,
|
||||||
|
headerPeerID: conversationPeerID,
|
||||||
|
displayName: displayName,
|
||||||
|
availability: .meshReachable,
|
||||||
|
isFavorite: false,
|
||||||
|
encryptionStatus: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct FingerprintPresentationState: Equatable {
|
struct FingerprintPresentationState: Equatable {
|
||||||
let statusPeerID: PeerID
|
|
||||||
let peerNickname: String
|
let peerNickname: String
|
||||||
let encryptionStatus: EncryptionStatus
|
let encryptionStatus: EncryptionStatus
|
||||||
let theirFingerprint: String?
|
let theirFingerprint: String?
|
||||||
let myFingerprint: String
|
let myFingerprint: String
|
||||||
let isVerified: Bool
|
let isVerified: Bool
|
||||||
|
/// Number of currently-valid vouches from peers the user verified
|
||||||
|
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
||||||
|
let voucherCount: Int
|
||||||
|
/// Display names of the (verified) vouchers, where known.
|
||||||
|
let voucherNames: [String]
|
||||||
|
|
||||||
|
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
|
||||||
|
var isVouched: Bool { voucherCount > 0 }
|
||||||
|
|
||||||
var canToggleVerification: Bool {
|
var canToggleVerification: Bool {
|
||||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||||
@@ -48,10 +55,6 @@ final class VerificationModel: ObservableObject {
|
|||||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
|
||||||
chatViewModel.beginQRVerification(with: qr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||||
return .invalid
|
return .invalid
|
||||||
@@ -82,14 +85,33 @@ final class VerificationModel: ObservableObject {
|
|||||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||||
|
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
|
|
||||||
|
// Vouch state is recomputed on read: only vouchers still in the
|
||||||
|
// verified set count, so removing a verification silently retires the
|
||||||
|
// vouches that peer gave.
|
||||||
|
let vouchers: [VouchRecord]
|
||||||
|
if !isVerified, let theirFingerprint {
|
||||||
|
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
|
||||||
|
} else {
|
||||||
|
vouchers = []
|
||||||
|
}
|
||||||
|
let voucherNames = vouchers.compactMap { record -> String? in
|
||||||
|
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let petname = social.localPetname, !petname.isEmpty { return petname }
|
||||||
|
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
|
||||||
|
}
|
||||||
|
|
||||||
return FingerprintPresentationState(
|
return FingerprintPresentationState(
|
||||||
statusPeerID: statusPeerID,
|
|
||||||
peerNickname: peerNickname,
|
peerNickname: peerNickname,
|
||||||
encryptionStatus: encryptionStatus,
|
encryptionStatus: encryptionStatus,
|
||||||
theirFingerprint: theirFingerprint,
|
theirFingerprint: theirFingerprint,
|
||||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
isVerified: isVerified,
|
||||||
|
voucherCount: vouchers.count,
|
||||||
|
voucherNames: voucherNames
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +144,17 @@ final class VerificationModel: ObservableObject {
|
|||||||
self?.objectWillChange.send()
|
self?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
|
||||||
|
// are signalled via this notification rather than a published
|
||||||
|
// property, so an open fingerprint sheet refreshes its vouched badge
|
||||||
|
// live when a vouch batch is accepted.
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 460 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 50 KiB |
@@ -27,6 +27,66 @@
|
|||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_16x16.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_16x16@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_32x32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_32x32@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_128x128.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_128x128@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_256x256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_256x256@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_512x512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_512x512@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -40,6 +40,7 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(runtime.locationChannelsModel)
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
.environmentObject(runtime.peerListModel)
|
.environmentObject(runtime.peerListModel)
|
||||||
.environmentObject(runtime.appChromeModel)
|
.environmentObject(runtime.appChromeModel)
|
||||||
|
.environmentObject(runtime.boardAlertsModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
runtime.start()
|
||||||
@@ -103,12 +104,20 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
|
let actionIdentifier = response.actionIdentifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
|
// Complete only after the response is handled: for a background
|
||||||
|
// action (👋 wave) the system may suspend the app the moment the
|
||||||
|
// completion handler runs, which would drop the queued send.
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
self.runtime?.handleNotificationResponse(
|
||||||
|
identifier: identifier,
|
||||||
|
actionIdentifier: actionIdentifier,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
completionHandler()
|
||||||
}
|
}
|
||||||
completionHandler()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@@ -14,28 +13,11 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.85
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
|
private static let targetImageBytes: Int = 45_000
|
||||||
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
|
|
||||||
// `compressionQuality`, and only steps quality down when a payload would
|
|
||||||
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
|
|
||||||
// (512 KiB) so the BLE path never overruns its cap.
|
|
||||||
//
|
|
||||||
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
|
|
||||||
// to ~40 KB — below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) — so
|
|
||||||
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
|
|
||||||
// in production. A genuinely detailed photo at `defaultMaxDimension` now
|
|
||||||
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
|
|
||||||
// direct peer while still riding BLE fragmentation for everyone else.
|
|
||||||
private static let targetImageBytes: Int = 200_000
|
|
||||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||||
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
|
|
||||||
// (together with the tiny byte budget) forced every photo below the Wi-Fi
|
|
||||||
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
|
|
||||||
// images clear 64 KiB, without approaching the 512 KiB hard cap.
|
|
||||||
static let defaultMaxDimension: CGFloat = 1024
|
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
try validateImageSource(at: url)
|
try validateImageSource(at: url)
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
@@ -65,33 +47,34 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
var dimension = maxDimension
|
// Scale the image first
|
||||||
var jpegData: Data?
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
// Downscale-and-compress until the payload fits the hard image cap.
|
|
||||||
// A normal photo converges on the first pass; this loop only kicks
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
// in for near-incompressible inputs (e.g. full-frame noise) that
|
guard let cgImage = scaled.cgImage else {
|
||||||
// would otherwise overrun `maxImageBytes` at the raised dimension.
|
throw ImageUtilsError.encodingFailed
|
||||||
while true {
|
}
|
||||||
let scaled = scaledImage(image, maxDimension: dimension)
|
|
||||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||||
guard let cgImage = scaled.cgImage else {
|
var quality = compressionQuality
|
||||||
throw ImageUtilsError.encodingFailed
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress to target size
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard let data = compressToBudget(cgImage) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
jpegData = data
|
|
||||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
|
||||||
}
|
}
|
||||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try finalData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,93 +93,6 @@ enum ImageUtils {
|
|||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
return rendered ?? image
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
|
||||||
return try autoreleasepool {
|
|
||||||
var dimension = maxDimension
|
|
||||||
var jpegData: Data?
|
|
||||||
// See the iOS path: normal photos converge immediately; the loop
|
|
||||||
// only shrinks further for near-incompressible inputs so the
|
|
||||||
// output never overruns `maxImageBytes`.
|
|
||||||
while true {
|
|
||||||
let scaled = scaledImage(image, maxDimension: dimension)
|
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
let width = inputCG.width
|
|
||||||
let height = inputCG.height
|
|
||||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
|
||||||
guard let context = CGContext(
|
|
||||||
data: nil,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
bitsPerComponent: 8,
|
|
||||||
bytesPerRow: 0,
|
|
||||||
space: colorSpace,
|
|
||||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
||||||
) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
guard let cgImage = context.makeImage() else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
guard let data = compressToBudget(cgImage) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
jpegData = data
|
|
||||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
|
||||||
}
|
|
||||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
|
||||||
try finalData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
|
||||||
let size = image.size
|
|
||||||
let maxSide = max(size.width, size.height)
|
|
||||||
guard maxSide > maxDimension else { return image }
|
|
||||||
let scale = maxDimension / maxSide
|
|
||||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
|
||||||
let scaledImage = NSImage(size: newSize)
|
|
||||||
scaledImage.lockFocus()
|
|
||||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
|
||||||
from: NSRect(origin: .zero, size: size),
|
|
||||||
operation: .copy,
|
|
||||||
fraction: 1.0)
|
|
||||||
scaledImage.unlockFocus()
|
|
||||||
return scaledImage
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// When even the quality floor can't get an image under the byte budget,
|
|
||||||
// shrink the longest side by this factor and re-encode. Bounded below so
|
|
||||||
// the retry loop always terminates.
|
|
||||||
private static let dimensionRetryFactor: CGFloat = 0.75
|
|
||||||
private static let minRetryDimension: CGFloat = 256
|
|
||||||
|
|
||||||
/// Encodes `cgImage` to JPEG, stepping quality down toward
|
|
||||||
/// `targetImageBytes`. Shared by both platforms.
|
|
||||||
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return jpegData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
@@ -218,6 +114,86 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
let width = inputCG.width
|
||||||
|
let height = inputCG.height
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
let scaledImage = NSImage(size: newSize)
|
||||||
|
scaledImage.lockFocus()
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||||
|
from: NSRect(origin: .zero, size: size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
scaledImage.unlockFocus()
|
||||||
|
return scaledImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// PTTAudioCodec.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
|
||||||
|
/// carries a bit reservoir across frames); one instance per burst.
|
||||||
|
/// Not thread-safe — confine to one queue.
|
||||||
|
final class PTTFrameEncoder {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private var pendingInput: [AVAudioPCMBuffer] = []
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let aac = PTTAudioFormat.aacFormat,
|
||||||
|
let converter = AVAudioConverter(from: pcm, to: aac)
|
||||||
|
else { return nil }
|
||||||
|
converter.bitRate = PTTAudioFormat.bitRate
|
||||||
|
self.converter = converter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
|
||||||
|
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
|
||||||
|
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
|
||||||
|
pendingInput.append(buffer)
|
||||||
|
return drainConverter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainConverter() -> [Data] {
|
||||||
|
var frames: [Data] = []
|
||||||
|
while true {
|
||||||
|
let output = AVAudioCompressedBuffer(
|
||||||
|
format: converter.outputFormat,
|
||||||
|
packetCapacity: 8,
|
||||||
|
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
|
||||||
|
)
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
|
||||||
|
guard let self, let next = self.pendingInput.first else {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.pendingInput.removeFirst()
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
if status == .error {
|
||||||
|
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
frames.append(contentsOf: Self.extractPackets(from: output))
|
||||||
|
// .haveData means the output buffer filled and more may be ready;
|
||||||
|
// anything else means the converter wants more input.
|
||||||
|
if status != .haveData { return frames }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
|
||||||
|
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
|
||||||
|
var frames: [Data] = []
|
||||||
|
frames.reserveCapacity(Int(buffer.packetCount))
|
||||||
|
for index in 0..<Int(buffer.packetCount) {
|
||||||
|
let description = descriptions[index]
|
||||||
|
guard description.mDataByteSize > 0 else { continue }
|
||||||
|
let start = buffer.data.advanced(by: Int(description.mStartOffset))
|
||||||
|
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
|
||||||
|
/// inbound burst. Not thread-safe — confine to one queue/actor.
|
||||||
|
final class PTTFrameDecoder {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private let pcmFormat: AVAudioFormat
|
||||||
|
private let aacFormat: AVAudioFormat
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let aac = PTTAudioFormat.aacFormat,
|
||||||
|
let converter = AVAudioConverter(from: aac, to: pcm)
|
||||||
|
else { return nil }
|
||||||
|
self.converter = converter
|
||||||
|
self.pcmFormat = pcm
|
||||||
|
self.aacFormat = aac
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
|
||||||
|
/// while the decoder is still priming (the first frame of a stream).
|
||||||
|
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
|
||||||
|
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
|
||||||
|
|
||||||
|
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
|
||||||
|
frame.withUnsafeBytes { raw in
|
||||||
|
guard let base = raw.baseAddress else { return }
|
||||||
|
input.data.copyMemory(from: base, byteCount: frame.count)
|
||||||
|
}
|
||||||
|
input.byteLength = UInt32(frame.count)
|
||||||
|
input.packetCount = 1
|
||||||
|
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
||||||
|
mStartOffset: 0,
|
||||||
|
mVariableFramesInPacket: 0,
|
||||||
|
mDataByteSize: UInt32(frame.count)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let output = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: pcmFormat,
|
||||||
|
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
var consumed = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if consumed {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
guard status != .error else {
|
||||||
|
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output.frameLength > 0 ? output : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample-rate/channel converter from the microphone's native format to the
|
||||||
|
/// 16 kHz mono processing format. Stateful; not thread-safe.
|
||||||
|
final class PTTInputResampler {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private let outputFormat: AVAudioFormat
|
||||||
|
private let ratio: Double
|
||||||
|
|
||||||
|
init?(inputFormat: AVAudioFormat) {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let converter = AVAudioConverter(from: inputFormat, to: pcm)
|
||||||
|
else { return nil }
|
||||||
|
self.converter = converter
|
||||||
|
self.outputFormat = pcm
|
||||||
|
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||||
|
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
||||||
|
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
|
||||||
|
|
||||||
|
var consumed = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if consumed {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
guard status != .error else {
|
||||||
|
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output.frameLength > 0 ? output : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// PTTAudioFormat.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
|
||||||
|
/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings
|
||||||
|
/// so a burst's finalized `.m4a` and its live frames sound the same.
|
||||||
|
enum PTTAudioFormat {
|
||||||
|
static let sampleRate: Double = 16_000
|
||||||
|
static let channelCount: AVAudioChannelCount = 1
|
||||||
|
static let bitRate = 16_000
|
||||||
|
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
|
||||||
|
static let samplesPerFrame: AVAudioFrameCount = 1024
|
||||||
|
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
|
||||||
|
|
||||||
|
/// Uncompressed processing format (deinterleaved float PCM).
|
||||||
|
static var pcmFormat: AVAudioFormat? {
|
||||||
|
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compressed wire format.
|
||||||
|
static var aacFormat: AVAudioFormat? {
|
||||||
|
var description = AudioStreamBasicDescription(
|
||||||
|
mSampleRate: sampleRate,
|
||||||
|
mFormatID: kAudioFormatMPEG4AAC,
|
||||||
|
mFormatFlags: 0,
|
||||||
|
mBytesPerPacket: 0,
|
||||||
|
mFramesPerPacket: samplesPerFrame,
|
||||||
|
mBytesPerFrame: 0,
|
||||||
|
mChannelsPerFrame: channelCount,
|
||||||
|
mBitsPerChannel: 0,
|
||||||
|
mReserved: 0
|
||||||
|
)
|
||||||
|
return AVAudioFormat(streamDescription: &description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice-note container settings for the finalized `.m4a`, mirroring
|
||||||
|
/// `VoiceRecorder.startRecording()`.
|
||||||
|
static var voiceNoteFileSettings: [String: Any] {
|
||||||
|
[
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: sampleRate,
|
||||||
|
AVNumberOfChannelsKey: Int(channelCount),
|
||||||
|
AVEncoderBitRateKey: bitRate
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
|
||||||
|
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
|
||||||
|
/// stream is playable at any prefix — a partially received burst is still a
|
||||||
|
/// replayable voice note.
|
||||||
|
enum ADTSFramer {
|
||||||
|
private static let headerSize = 7
|
||||||
|
/// MPEG-4 sampling frequency index for 16 kHz.
|
||||||
|
private static let samplingFrequencyIndex: UInt8 = 8
|
||||||
|
private static let channelConfiguration: UInt8 = 1
|
||||||
|
|
||||||
|
/// Wraps one raw AAC-LC frame in an ADTS header.
|
||||||
|
static func frame(_ aacFrame: Data) -> Data {
|
||||||
|
let frameLength = aacFrame.count + headerSize
|
||||||
|
var data = Data(capacity: frameLength)
|
||||||
|
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
|
||||||
|
data.append(0xFF)
|
||||||
|
data.append(0xF1)
|
||||||
|
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
|
||||||
|
// private bit 0, channel config high bit.
|
||||||
|
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
|
||||||
|
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
|
||||||
|
data.append(UInt8((frameLength >> 3) & 0xFF))
|
||||||
|
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
|
||||||
|
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
|
||||||
|
data.append(0xFC)
|
||||||
|
data.append(aacFrame)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// PTTBurstPlayer.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Plays one inbound live voice burst with a small jitter buffer.
|
||||||
|
///
|
||||||
|
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
||||||
|
/// an underrun (missing/late packets) simply pauses output until the next
|
||||||
|
/// buffer arrives, which self-heals timing without explicit silence
|
||||||
|
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
||||||
|
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
||||||
|
@MainActor
|
||||||
|
final class PTTBurstPlayer {
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private let node = AVAudioPlayerNode()
|
||||||
|
private let decoder: PTTFrameDecoder
|
||||||
|
|
||||||
|
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
||||||
|
private var queuedDuration: TimeInterval = 0
|
||||||
|
private var scheduledCount = 0
|
||||||
|
private var engineStarted = false
|
||||||
|
private var finished = false
|
||||||
|
private var stopped = false
|
||||||
|
private var deadlineTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private(set) var isPlaying = false
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||||
|
self.decoder = decoder
|
||||||
|
engine.attach(node)
|
||||||
|
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||||
|
|
||||||
|
deadlineTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
||||||
|
self?.startIfReady(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes and queues frames (in burst order). Starts playback when the
|
||||||
|
/// jitter buffer fills.
|
||||||
|
func enqueue(_ frames: [Data]) {
|
||||||
|
guard !stopped else { return }
|
||||||
|
for frame in frames {
|
||||||
|
guard let pcm = decoder.decode(frame) else { continue }
|
||||||
|
if engineStarted {
|
||||||
|
schedule(pcm)
|
||||||
|
} else {
|
||||||
|
queuedBuffers.append(pcm)
|
||||||
|
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startIfReady(force: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The burst ended: stop once everything scheduled has played out.
|
||||||
|
func finishAfterDrain() {
|
||||||
|
finished = true
|
||||||
|
stopIfDrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immediate stop (cancel, another playback taking over, teardown).
|
||||||
|
func stop() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
stopped = true
|
||||||
|
deadlineTask?.cancel()
|
||||||
|
queuedBuffers = []
|
||||||
|
if engineStarted {
|
||||||
|
node.stop()
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startIfReady(force: Bool) {
|
||||||
|
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
|
||||||
|
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
engine.prepare()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||||
|
stopped = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engineStarted = true
|
||||||
|
isPlaying = true
|
||||||
|
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||||
|
node.play()
|
||||||
|
|
||||||
|
let buffered = queuedBuffers
|
||||||
|
queuedBuffers = []
|
||||||
|
queuedDuration = 0
|
||||||
|
for buffer in buffered {
|
||||||
|
schedule(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||||
|
scheduledCount += 1
|
||||||
|
node.scheduleBuffer(buffer) { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scheduledCount -= 1
|
||||||
|
self.stopIfDrained()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopIfDrained() {
|
||||||
|
guard finished, scheduledCount <= 0 else { return }
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PTTBurstPlayer: ExclusivePlayback {
|
||||||
|
/// A live stream can't meaningfully pause; yielding the floor stops it.
|
||||||
|
/// The burst keeps assembling to file, so nothing is lost.
|
||||||
|
nonisolated func pauseForExclusivity() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// PTTCaptureEngine.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
||||||
|
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
||||||
|
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
||||||
|
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
||||||
|
/// handles delivery to receivers that missed the live stream.
|
||||||
|
final class PTTCaptureEngine {
|
||||||
|
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
||||||
|
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
||||||
|
private static let maxCaptureDuration: TimeInterval = 120
|
||||||
|
|
||||||
|
/// Recreated on every `start()`: an engine whose input unit was
|
||||||
|
/// instantiated against an earlier (playback-only or inactive) audio
|
||||||
|
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
|
||||||
|
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
||||||
|
private var engine = AVAudioEngine()
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
||||||
|
|
||||||
|
// Capture-queue-confined state.
|
||||||
|
private var resampler: PTTInputResampler?
|
||||||
|
private var encoder: PTTFrameEncoder?
|
||||||
|
private var file: AVAudioFile?
|
||||||
|
private var fileURL: URL?
|
||||||
|
private var encodedFrameCount = 0
|
||||||
|
private var running = false
|
||||||
|
private var captureStart = Date()
|
||||||
|
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
||||||
|
/// callers only; see `stopEngineIfStarted`).
|
||||||
|
private var engineStarted = false
|
||||||
|
|
||||||
|
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||||
|
var onFrames: (([Data]) -> Void)?
|
||||||
|
|
||||||
|
enum CaptureError: Error {
|
||||||
|
case inputUnavailable
|
||||||
|
case audioSetupFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(outputURL: URL) throws {
|
||||||
|
#if os(iOS)
|
||||||
|
try Self.configureAudioSession()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Fresh engine per capture so its input unit binds to the session
|
||||||
|
// that is active *now* (see `engine` doc comment).
|
||||||
|
engine = AVAudioEngine()
|
||||||
|
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||||
|
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
||||||
|
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||||
|
throw CaptureError.inputUnavailable
|
||||||
|
}
|
||||||
|
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
|
||||||
|
let encoder = PTTFrameEncoder(),
|
||||||
|
let pcmFormat = PTTAudioFormat.pcmFormat
|
||||||
|
else { throw CaptureError.audioSetupFailed }
|
||||||
|
|
||||||
|
let file = try AVAudioFile(
|
||||||
|
forWriting: outputURL,
|
||||||
|
settings: PTTAudioFormat.voiceNoteFileSettings,
|
||||||
|
commonFormat: pcmFormat.commonFormat,
|
||||||
|
interleaved: pcmFormat.isInterleaved
|
||||||
|
)
|
||||||
|
|
||||||
|
queue.sync {
|
||||||
|
self.resampler = resampler
|
||||||
|
self.encoder = encoder
|
||||||
|
self.file = file
|
||||||
|
self.fileURL = outputURL
|
||||||
|
self.encodedFrameCount = 0
|
||||||
|
self.captureStart = Date()
|
||||||
|
self.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||||
|
self?.queue.async { self?.process(buffer) }
|
||||||
|
}
|
||||||
|
engine.prepare()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
queue.sync { self.teardown(deleteFile: true) }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
engineStarted = true
|
||||||
|
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
||||||
|
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
||||||
|
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||||
|
stopEngineIfStarted()
|
||||||
|
let result: (URL?, Int) = queue.sync {
|
||||||
|
let url = fileURL
|
||||||
|
let frames = encodedFrameCount
|
||||||
|
teardown(deleteFile: false)
|
||||||
|
return (url, frames)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
Self.deactivateAudioSession()
|
||||||
|
#endif
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
stopEngineIfStarted()
|
||||||
|
queue.sync { teardown(deleteFile: true) }
|
||||||
|
#if os(iOS)
|
||||||
|
Self.deactivateAudioSession()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touching `inputNode` on an engine that never started instantiates its
|
||||||
|
/// input unit against whatever session is active and spams AURemoteIO
|
||||||
|
/// errors — a canceled-before-start hold must not touch the engine.
|
||||||
|
private func stopEngineIfStarted() {
|
||||||
|
guard engineStarted else { return }
|
||||||
|
engineStarted = false
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture queue
|
||||||
|
|
||||||
|
private func process(_ buffer: AVAudioPCMBuffer) {
|
||||||
|
guard running,
|
||||||
|
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||||
|
let resampled = resampler?.resample(buffer)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try file?.write(from: resampled)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
|
||||||
|
encodedFrameCount += frames.count
|
||||||
|
onFrames?(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardown(deleteFile: Bool) {
|
||||||
|
running = false
|
||||||
|
// Releasing the AVAudioFile finalizes the .m4a container.
|
||||||
|
file = nil
|
||||||
|
encoder = nil
|
||||||
|
resampler = nil
|
||||||
|
if deleteFile, let url = fileURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
fileURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio session (iOS)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private static func configureAudioSession() throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
|
||||||
|
#else
|
||||||
|
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
|
||||||
|
#endif
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deactivateAudioSession() {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// PTTSettings.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// User preference for live push-to-talk voice. One switch controls both
|
||||||
|
/// directions: streaming your holds live, and auto-playing inbound bursts.
|
||||||
|
/// Off means voice messages behave exactly like classic voice notes.
|
||||||
|
enum PTTSettings {
|
||||||
|
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
|
||||||
|
|
||||||
|
static var liveVoiceEnabled: Bool {
|
||||||
|
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autoplay is foreground-only: audio must never start from the
|
||||||
|
/// background.
|
||||||
|
@MainActor
|
||||||
|
static var isAppActive: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return UIApplication.shared.applicationState == .active
|
||||||
|
#elseif os(macOS)
|
||||||
|
return NSApplication.shared.isActive
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// VoiceCaptureSession.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 Foundation
|
||||||
|
|
||||||
|
/// Capture backend behind the composer's hold-to-record gesture.
|
||||||
|
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
|
||||||
|
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
|
||||||
|
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
|
||||||
|
/// additionally streams frames live while the button is held.
|
||||||
|
@MainActor
|
||||||
|
protocol VoiceCaptureSession: AnyObject {
|
||||||
|
/// Whether audio is leaving the device in real time while recording —
|
||||||
|
/// drives the composer's LIVE treatment.
|
||||||
|
var isLive: Bool { get }
|
||||||
|
func requestPermission() async -> Bool
|
||||||
|
func start() async throws
|
||||||
|
/// Stops capture and returns the finalized voice-note file, or nil when
|
||||||
|
/// nothing valid was captured.
|
||||||
|
func finish() async -> URL?
|
||||||
|
func cancel() async
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
|
@MainActor
|
||||||
|
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||||
|
var isLive: Bool { false }
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
await VoiceRecorder.shared.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
try await VoiceRecorder.shared.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async -> URL? {
|
||||||
|
await VoiceRecorder.shared.stopRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
await VoiceRecorder.shared.cancelRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
||||||
|
/// recording, then finalizes the same audio as a standard voice note whose
|
||||||
|
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
||||||
|
/// heard the live stream absorb the note silently instead of seeing a
|
||||||
|
/// duplicate.
|
||||||
|
@MainActor
|
||||||
|
final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||||
|
let burstID: Data
|
||||||
|
|
||||||
|
private let sendPacket: (Data) -> Void
|
||||||
|
private let capture = PTTCaptureEngine()
|
||||||
|
/// Capture-queue-confined stream state: packetizes frames and lazily
|
||||||
|
/// emits START so packet order is guaranteed by queue serialization.
|
||||||
|
private final class StreamState {
|
||||||
|
var packetizer: VoiceBurstPacketizer
|
||||||
|
var sentStart = false
|
||||||
|
init(burstID: Data) {
|
||||||
|
packetizer = VoiceBurstPacketizer(burstID: burstID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private let stream: StreamState
|
||||||
|
private var startDate: Date?
|
||||||
|
private var completed = false
|
||||||
|
|
||||||
|
var isLive: Bool { true }
|
||||||
|
|
||||||
|
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
||||||
|
/// target peer; must be safe to call from any queue (BLEService hops to
|
||||||
|
/// its own message queue internally).
|
||||||
|
init(sendPacket: @escaping (Data) -> Void) {
|
||||||
|
self.burstID = VoiceBurstPacket.makeBurstID()
|
||||||
|
self.sendPacket = sendPacket
|
||||||
|
self.stream = StreamState(burstID: burstID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
await VoiceRecorder.shared.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
let outputURL = try Self.makeOutputURL(burstID: burstID)
|
||||||
|
let sendPacket = sendPacket
|
||||||
|
let stream = stream
|
||||||
|
capture.onFrames = { frames in
|
||||||
|
if !stream.sentStart {
|
||||||
|
stream.sentStart = true
|
||||||
|
if let start = VoiceBurstPacket(
|
||||||
|
burstID: stream.packetizer.burstID,
|
||||||
|
seq: 0,
|
||||||
|
kind: .start(codec: .aacLC16kMono)
|
||||||
|
) {
|
||||||
|
sendPacket(start.encode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for frame in frames {
|
||||||
|
for packet in stream.packetizer.add(frame) {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush per callback batch: at ~130-byte frames the budget fits
|
||||||
|
// one frame per packet anyway, and holding residue would add
|
||||||
|
// ~100 ms of avoidable latency.
|
||||||
|
for packet in stream.packetizer.flush() {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try capture.start(outputURL: outputURL)
|
||||||
|
} catch {
|
||||||
|
// The HAL can briefly report a dead input right after the audio
|
||||||
|
// session (re)activates while the route settles; one retry after
|
||||||
|
// a short pause covers it (observed on iPhone field tests).
|
||||||
|
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
|
||||||
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
try capture.start(outputURL: outputURL)
|
||||||
|
}
|
||||||
|
startDate = Date()
|
||||||
|
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async -> URL? {
|
||||||
|
guard !completed else { return nil }
|
||||||
|
completed = true
|
||||||
|
|
||||||
|
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
let (url, encodedFrames) = capture.stop()
|
||||||
|
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||||
|
|
||||||
|
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
|
||||||
|
sendControlPacket(.canceled)
|
||||||
|
if let url {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for packet in stream.packetizer.flush() {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
|
||||||
|
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
|
||||||
|
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
guard !completed else { return }
|
||||||
|
completed = true
|
||||||
|
capture.cancel()
|
||||||
|
sendControlPacket(.canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
|
sendPacket(packet.encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeOutputURL(burstID: Data) throws -> URL {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,23 +181,35 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures only one voice note plays at a time.
|
/// Something that can hold the app's single audio-playback slot and yield it
|
||||||
|
/// when another playback starts (voice notes pause; live bursts stop).
|
||||||
|
protocol ExclusivePlayback: AnyObject {
|
||||||
|
func pauseForExclusivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||||
|
func pauseForExclusivity() {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures only one voice playback (note or live burst) runs at a time.
|
||||||
final class VoiceNotePlaybackCoordinator {
|
final class VoiceNotePlaybackCoordinator {
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
private weak var activeController: VoiceNotePlaybackController?
|
private weak var activeController: (any ExclusivePlayback)?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func activate(_ controller: VoiceNotePlaybackController) {
|
func activate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeController?.pause()
|
activeController?.pauseForExclusivity()
|
||||||
activeController = controller
|
activeController = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
func deactivate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
activeController = nil
|
activeController = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import AVFoundation
|
|||||||
actor VoiceRecorder {
|
actor VoiceRecorder {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
|
||||||
case recordingInProgress
|
case recordingInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ final class WaveformCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func purgeAll() {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
guard bins > 0 else { return nil }
|
guard bins > 0 else { return nil }
|
||||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ import BitFoundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: PeerID // 8 random bytes
|
|
||||||
let sessionStart: Date
|
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +96,6 @@ enum HandshakeState {
|
|||||||
case initiated
|
case initiated
|
||||||
case inProgress
|
case inProgress
|
||||||
case completed(fingerprint: String)
|
case completed(fingerprint: String)
|
||||||
case failed(reason: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||||
@@ -110,7 +107,6 @@ struct CryptographicIdentity: Codable {
|
|||||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||||
var signingPublicKey: Data? = nil
|
var signingPublicKey: Data? = nil
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
let lastHandshake: Date?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||||
@@ -126,13 +122,37 @@ struct SocialIdentity: Codable {
|
|||||||
var notes: String?
|
var notes: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trust ladder: unknown → casual → vouched → trusted → verified.
|
||||||
|
///
|
||||||
|
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
|
||||||
|
/// value ("unknown", "casual", …), not by ordinal position, so inserting
|
||||||
|
/// `vouched` mid-ladder cannot corrupt previously persisted values — every
|
||||||
|
/// pre-existing case keeps the exact raw value it was written with. The
|
||||||
|
/// `vouched` tier is additionally never persisted into `SocialIdentity`
|
||||||
|
/// (it's recomputed on read from stored vouches), so downgraded builds never
|
||||||
|
/// encounter the unfamiliar raw value.
|
||||||
enum TrustLevel: String, Codable {
|
enum TrustLevel: String, Codable {
|
||||||
case unknown
|
case unknown
|
||||||
case casual
|
case casual
|
||||||
|
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
||||||
|
/// Derived at read time — never written to persistent storage.
|
||||||
|
case vouched
|
||||||
case trusted
|
case trusted
|
||||||
case verified
|
case verified
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Vouching (transitive verification)
|
||||||
|
|
||||||
|
/// One accepted vouch: a peer I verified (the voucher) attested that they
|
||||||
|
/// verified the vouchee. Validity is recomputed on read — a record only
|
||||||
|
/// counts while its voucher remains in `verifiedFingerprints` and its
|
||||||
|
/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher
|
||||||
|
/// silently invalidates the vouches they gave without a cascade delete.
|
||||||
|
struct VouchRecord: Codable, Equatable {
|
||||||
|
let voucherFingerprint: String
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Identity Cache
|
// MARK: - Identity Cache
|
||||||
|
|
||||||
/// Persistent storage for identity mappings and relationships.
|
/// Persistent storage for identity mappings and relationships.
|
||||||
@@ -154,9 +174,21 @@ struct IdentityCache: Codable {
|
|||||||
|
|
||||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
var blockedNostrPubkeys: Set<String> = []
|
var blockedNostrPubkeys: Set<String> = []
|
||||||
|
|
||||||
// Schema version for future migrations
|
// Vouching (transitive verification). All three fields are Optional so
|
||||||
var version: Int = 1
|
// caches persisted before this feature decode cleanly — the synthesized
|
||||||
|
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||||
|
// trip the "unreadable cache" recovery path that discards everything.
|
||||||
|
|
||||||
|
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||||
|
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||||
|
|
||||||
|
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
|
||||||
|
var vouchBatchSentAt: [String: Date]? = nil
|
||||||
|
|
||||||
|
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||||
|
// entries verified before this field exists sort as oldest)
|
||||||
|
var verifiedAt: [String: Date]? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -108,8 +108,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
func getFavorites() -> Set<String>
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
// MARK: Blocked Users Management
|
// MARK: Blocked Users Management
|
||||||
@@ -123,8 +121,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: PeerID)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
@@ -133,6 +130,16 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
func isVerified(fingerprint: String) -> Bool
|
func isVerified(fingerprint: String) -> Bool
|
||||||
func getVerifiedFingerprints() -> Set<String>
|
func getVerifiedFingerprints() -> Set<String>
|
||||||
|
|
||||||
|
// MARK: Vouching (transitive verification)
|
||||||
|
@discardableResult
|
||||||
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||||
|
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||||
|
func isVouched(fingerprint: String) -> Bool
|
||||||
|
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||||
|
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||||
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
|
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Singleton manager for secure identity state persistence and retrieval.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
@@ -311,21 +318,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen
|
||||||
lastHandshake: now
|
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
} else {
|
} else {
|
||||||
// Update signing key and lastHandshake
|
// Update signing key
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
let updated = CryptographicIdentity(
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
fingerprint: existing.fingerprint,
|
|
||||||
publicKey: existing.publicKey,
|
|
||||||
signingPublicKey: existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = updated
|
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -334,8 +333,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
firstSeen: now,
|
firstSeen: now
|
||||||
lastHandshake: now
|
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
@@ -500,11 +498,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||||
peerID: peerID,
|
|
||||||
sessionStart: Date(),
|
|
||||||
handshakeState: handshakeState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,16 +544,20 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
|
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||||
|
verifiedAt[fingerprint] = Date()
|
||||||
|
self.cache.verifiedAt = verifiedAt
|
||||||
} else {
|
} else {
|
||||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||||
|
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trust level if social identity exists
|
// Update trust level if social identity exists
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.trustLevel = verified ? .verified : .casual
|
identity.trustLevel = verified ? .verified : .casual
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
}
|
}
|
||||||
|
|
||||||
self.saveIdentityCache()
|
self.saveIdentityCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,6 +574,159 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Vouching (transitive verification)
|
||||||
|
|
||||||
|
/// Maximum vouchers retained per vouchee (most recent kept).
|
||||||
|
static let maxVouchersPerVouchee = 8
|
||||||
|
|
||||||
|
/// Records an accepted vouch, enforcing every accept-policy gate that can
|
||||||
|
/// be evaluated against stored state (signature verification is the
|
||||||
|
/// caller's job — it needs the sender's announce-bound signing key):
|
||||||
|
/// - the voucher must be a fingerprint *I* verified
|
||||||
|
/// - self-vouches are ignored
|
||||||
|
/// - vouches for peers I already verified are ignored (nothing to add)
|
||||||
|
/// - attestations outside the validity window are ignored
|
||||||
|
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
|
||||||
|
///
|
||||||
|
/// Returns true when the vouch was stored (or refreshed).
|
||||||
|
@discardableResult
|
||||||
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||||
|
recordVouch(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucherFingerprint: voucherFingerprint,
|
||||||
|
timestamp: timestamp,
|
||||||
|
now: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
guard voucheeFingerprint != voucherFingerprint,
|
||||||
|
self.cache.verifiedFingerprints.contains(voucherFingerprint),
|
||||||
|
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let age = now.timeIntervalSince(timestamp)
|
||||||
|
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
|
||||||
|
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
|
||||||
|
let newest = max(records[index].timestamp, timestamp)
|
||||||
|
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
|
||||||
|
} else {
|
||||||
|
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
||||||
|
}
|
||||||
|
// Keep the most recent vouchers up to the cap.
|
||||||
|
records.sort { $0.timestamp > $1.timestamp }
|
||||||
|
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
|
||||||
|
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
|
||||||
|
return false // Full of fresher vouches; nothing changed.
|
||||||
|
}
|
||||||
|
|
||||||
|
var vouches = self.cache.vouchesByVouchee ?? [:]
|
||||||
|
vouches[voucheeFingerprint] = capped
|
||||||
|
self.cache.vouchesByVouchee = vouches
|
||||||
|
self.saveIdentityCache()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vouches that currently count for `fingerprint`. Validity is
|
||||||
|
/// recomputed here rather than maintained by cascade deletes: a record
|
||||||
|
/// only counts while its voucher is still verified-by-me and its
|
||||||
|
/// timestamp is within the expiry window.
|
||||||
|
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||||
|
validVouchers(for: fingerprint, now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||||
|
queue.sync {
|
||||||
|
self.validVouchersLocked(for: fingerprint, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requires `queue`.
|
||||||
|
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||||
|
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
|
||||||
|
return records.filter { record in
|
||||||
|
record.voucherFingerprint != fingerprint
|
||||||
|
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
|
||||||
|
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the peer has at least one valid vouch and no explicit
|
||||||
|
/// verification of ours.
|
||||||
|
func isVouched(fingerprint: String) -> Bool {
|
||||||
|
isVouched(fingerprint: fingerprint, now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVouched(fingerprint: String, now: Date) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
|
||||||
|
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trust level to display: explicit verification wins, then the
|
||||||
|
/// persisted level, with `vouched` layered in (derived, never persisted)
|
||||||
|
/// between `casual` and `trusted`.
|
||||||
|
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||||
|
effectiveTrustLevel(for: fingerprint, now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
|
||||||
|
queue.sync {
|
||||||
|
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
|
||||||
|
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
||||||
|
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||||
|
switch stored {
|
||||||
|
case .verified, .trusted:
|
||||||
|
return stored
|
||||||
|
case .vouched, .casual, .unknown:
|
||||||
|
if vouched { return .vouched }
|
||||||
|
// `.vouched` should never be persisted; degrade defensively.
|
||||||
|
return stored == .vouched ? .casual : stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||||
|
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
var sentAt = self.cache.vouchBatchSentAt ?? [:]
|
||||||
|
sentAt[fingerprint] = date
|
||||||
|
self.cache.vouchBatchSentAt = sentAt
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||||
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verified fingerprints ordered most recently verified first (entries
|
||||||
|
/// without a recorded verification time sort last), excluding the given
|
||||||
|
/// fingerprint. Feeds the outgoing vouch batch.
|
||||||
|
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||||
|
queue.sync {
|
||||||
|
let verifiedAt = cache.verifiedAt ?? [:]
|
||||||
|
let ordered = cache.verifiedFingerprints
|
||||||
|
.filter { $0 != fingerprint }
|
||||||
|
.sorted {
|
||||||
|
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
|
||||||
|
}
|
||||||
|
return Array(ordered.prefix(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var debugNicknameIndex: [String: Set<String>] {
|
var debugNicknameIndex: [String: Set<String>] {
|
||||||
queue.sync { cache.nicknameIndex }
|
queue.sync { cache.nicknameIndex }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,18 +33,12 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSBonjourServices</key>
|
|
||||||
<array>
|
|
||||||
<string>_bitchat-bulk._tcp</string>
|
|
||||||
</array>
|
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ extension BitchatMessage {
|
|||||||
enum Media {
|
enum Media {
|
||||||
case voice(URL)
|
case voice(URL)
|
||||||
case image(URL)
|
case image(URL)
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
switch self {
|
|
||||||
case .voice(let url), .image(let url):
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ struct BitchatPeer: Equatable {
|
|||||||
let peerID: PeerID // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let isReachable: Bool
|
let isReachable: Bool
|
||||||
|
|
||||||
@@ -77,14 +76,13 @@ struct BitchatPeer: Equatable {
|
|||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen _: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.peerID = peerID
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isReachable = isReachable
|
self.isReachable = isReachable
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,19 @@ enum CommandInfo: String, Identifiable {
|
|||||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
|
case group
|
||||||
case help
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "msg"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
|
case pay
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite = "fav"
|
case favorite = "fav"
|
||||||
case unfavorite = "unfav"
|
case unfavorite = "unfav"
|
||||||
|
case ping
|
||||||
|
case trace
|
||||||
|
case drop
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -31,8 +36,14 @@ enum CommandInfo: String, Identifiable {
|
|||||||
|
|
||||||
var placeholder: String? {
|
var placeholder: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
|
case .group:
|
||||||
|
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||||
|
case .pay:
|
||||||
|
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||||
|
case .drop:
|
||||||
|
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
||||||
case .clear, .help, .who:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -42,24 +53,36 @@ enum CommandInfo: String, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .block: String(localized: "content.commands.block")
|
case .block: String(localized: "content.commands.block")
|
||||||
case .clear: String(localized: "content.commands.clear")
|
case .clear: String(localized: "content.commands.clear")
|
||||||
|
case .group: String(localized: "content.commands.group")
|
||||||
case .help: String(localized: "content.commands.help")
|
case .help: String(localized: "content.commands.help")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .hug: String(localized: "content.commands.hug")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .message: String(localized: "content.commands.message")
|
||||||
|
case .pay: String(localized: "content.commands.pay")
|
||||||
case .slap: String(localized: "content.commands.slap")
|
case .slap: String(localized: "content.commands.slap")
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
case .unblock: String(localized: "content.commands.unblock")
|
||||||
case .who: String(localized: "content.commands.who")
|
case .who: String(localized: "content.commands.who")
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
case .favorite: String(localized: "content.commands.favorite")
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||||
|
case .ping: String(localized: "content.commands.ping")
|
||||||
|
case .trace: String(localized: "content.commands.trace")
|
||||||
|
case .drop: String(localized: "content.commands.drop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||||
// The processor rejects favorites in geohash contexts, so only
|
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||||
// suggest them where they actually work: mesh.
|
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||||
if isGeoPublic || isGeoDM {
|
// processor still allows it behind an explicit "public" confirm).
|
||||||
return baseCommands
|
// Payments make sense in every DM and in mesh public.
|
||||||
|
if !isGeoPublic {
|
||||||
|
commands.append(.pay)
|
||||||
}
|
}
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||||
|
// geohash contexts, so only suggest them where they work: mesh.
|
||||||
|
if isGeoPublic || isGeoDM {
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
|
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||||
|
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||||
|
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||||
|
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||||
|
// streams (targeted resync for stalled reassemblies)
|
||||||
struct RequestSyncPacket {
|
struct RequestSyncPacket {
|
||||||
|
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
||||||
|
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
||||||
|
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
||||||
|
static let maxFragmentIdFilterCount = 60
|
||||||
|
|
||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
@@ -12,6 +23,29 @@ struct RequestSyncPacket {
|
|||||||
let sinceTimestamp: UInt64?
|
let sinceTimestamp: UInt64?
|
||||||
let fragmentIdFilter: String?
|
let fragmentIdFilter: String?
|
||||||
|
|
||||||
|
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
||||||
|
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
||||||
|
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
||||||
|
let tokens = fragmentIDs
|
||||||
|
.filter { $0.count == 8 }
|
||||||
|
.prefix(maxFragmentIdFilterCount)
|
||||||
|
.map { $0.hexEncodedString() }
|
||||||
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
return tokens.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
||||||
|
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
||||||
|
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
||||||
|
guard let filter else { return nil }
|
||||||
|
var ids: Set<Data> = []
|
||||||
|
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
||||||
|
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
||||||
|
ids.insert(id)
|
||||||
|
}
|
||||||
|
return ids.isEmpty ? nil : ids
|
||||||
|
}
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||||
self.p = p
|
self.p = p
|
||||||
self.m = m
|
self.m = m
|
||||||
@@ -88,7 +122,9 @@ struct RequestSyncPacket {
|
|||||||
sinceTimestamp = ts
|
sinceTimestamp = ts
|
||||||
}
|
}
|
||||||
case 0x06:
|
case 0x06:
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
// Same acceptance cap as the GCS payload; an oversized filter
|
||||||
|
// is ignored rather than failing the whole request.
|
||||||
|
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
||||||
fragmentIdFilter = fid
|
fragmentIdFilter = fid
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
|
|||||||
return messageBuffer
|
return messageBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
|
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
|
||||||
|
|
||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ enum NoiseSecurityConstants {
|
|||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
static let maxHandshakesPerMinute = 10
|
static let maxHandshakesPerMinute = 10
|
||||||
static let maxMessagesPerSecond = 100
|
static let maxMessagesPerSecond = 100
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ enum NoiseSecurityError: Error {
|
|||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
case invalidPeerID
|
case invalidPeerID
|
||||||
case rateLimitExceeded
|
case rateLimitExceeded
|
||||||
case handshakeTimeout
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -23,8 +21,6 @@ final class NoiseSessionManager {
|
|||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
self.sessionFactory = { peerID, role in
|
self.sessionFactory = { peerID, role in
|
||||||
SecureNoiseSession(
|
SecureNoiseSession(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -37,12 +33,10 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
init(
|
init(
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain _: KeychainManagerProtocol,
|
||||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||||
) {
|
) {
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
self.sessionFactory = sessionFactory
|
self.sessionFactory = sessionFactory
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ struct NostrIdentity: Codable {
|
|||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
let npub: String // Bech32-encoded public key
|
let npub: String // Bech32-encoded public key
|
||||||
let createdAt: Date
|
|
||||||
|
|
||||||
/// Memberwise initializer
|
/// Memberwise initializer
|
||||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.npub = npub
|
self.npub = npub
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new Nostr identity
|
/// Generate a new Nostr identity
|
||||||
@@ -39,12 +37,6 @@ struct NostrIdentity: Codable {
|
|||||||
self.privateKey = privateKeyData
|
self.privateKey = privateKeyData
|
||||||
self.publicKey = xOnlyPubkey
|
self.publicKey = xOnlyPubkey
|
||||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||||
self.createdAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get signing key for event signatures
|
|
||||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
|
||||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Schnorr signing key for Nostr event signatures
|
/// Get Schnorr signing key for Nostr event signatures
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +37,6 @@ final class NostrIdentityBridge {
|
|||||||
return nostrIdentity
|
return nostrIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
/// Get Nostr public key associated with a Noise public key
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
@@ -57,29 +49,10 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
func clearAllAssociations() {
|
func clearAllAssociations() {
|
||||||
let query: [String: Any] = [
|
// Must go through the injected keychain, not raw SecItem calls:
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
// under test that keychain is in-memory, and a direct delete here
|
||||||
kSecAttrService as String: keychainService,
|
// would wipe the developer's real Nostr identity on every test run.
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
keychain.deleteAll(service: keychainService)
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache = nil
|
||||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||||
@@ -113,6 +86,13 @@ final class NostrIdentityBridge {
|
|||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
|
||||||
|
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
|
||||||
|
/// geohash-chat identity for the same cell string.
|
||||||
|
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
|
||||||
|
try deriveIdentity(forGeohash: "bridge|" + cell)
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// NIP-13 proof-of-work for Nostr events.
|
||||||
|
///
|
||||||
|
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
||||||
|
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
||||||
|
/// events are scored (never hard-rejected — the network has clients that do
|
||||||
|
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
||||||
|
/// per-sender public rate limit, everything else keeps the strict limits.
|
||||||
|
enum NostrPoW {
|
||||||
|
|
||||||
|
// MARK: - Tuning
|
||||||
|
|
||||||
|
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
||||||
|
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
||||||
|
/// 100 ms on any supported device.
|
||||||
|
static let targetBits = 8
|
||||||
|
|
||||||
|
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
||||||
|
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
||||||
|
/// still applies). See `MessageRateLimiter.allow`.
|
||||||
|
static let rateLimitBypassBits = 8
|
||||||
|
|
||||||
|
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
||||||
|
/// steps down until a difficulty reachable in a small extra budget is
|
||||||
|
/// found and the message is sent anyway — mining never blocks sending.
|
||||||
|
static let miningTimeCap: TimeInterval = 2.0
|
||||||
|
|
||||||
|
/// Budget for each stepped-down attempt after the main cap (or a task
|
||||||
|
/// cancellation) hits.
|
||||||
|
private static let fallbackTimeCap: TimeInterval = 0.15
|
||||||
|
|
||||||
|
/// The hot loop checks the deadline and task cancellation every this many
|
||||||
|
/// hash attempts.
|
||||||
|
private static let checkInterval: UInt64 = 1024
|
||||||
|
|
||||||
|
/// The nonce value is a fixed-width hex counter so the serialized event
|
||||||
|
/// template can be mutated in place without reallocation.
|
||||||
|
private static let nonceLength = 16
|
||||||
|
|
||||||
|
// MARK: - Scoring
|
||||||
|
|
||||||
|
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
||||||
|
/// an event-ID hash).
|
||||||
|
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
||||||
|
var total = 0
|
||||||
|
for byte in bytes {
|
||||||
|
if byte == 0 {
|
||||||
|
total += 8
|
||||||
|
} else {
|
||||||
|
total += byte.leadingZeroBitCount
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated NIP-13 difficulty of an inbound event.
|
||||||
|
///
|
||||||
|
/// The committed target in the nonce tag is what counts: the actual
|
||||||
|
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
||||||
|
/// and the event scores 0), and work beyond the commitment earns no extra
|
||||||
|
/// credit — this stops spammers who mine a low target from getting lucky
|
||||||
|
/// high scores. Events without a well-formed commitment score 0.
|
||||||
|
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
||||||
|
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
||||||
|
nonceTag.count >= 3,
|
||||||
|
let committed = Int(nonceTag[2]),
|
||||||
|
committed > 0, committed <= 256,
|
||||||
|
let idData = Data(hexString: idHex)
|
||||||
|
else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return leadingZeroBits(idData) >= committed ? committed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mining
|
||||||
|
|
||||||
|
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
||||||
|
/// fields. Nonisolated async: runs off the calling actor.
|
||||||
|
///
|
||||||
|
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
||||||
|
/// task is cancelled — the committed target steps down (halving to 0,
|
||||||
|
/// which any hash satisfies) so the event still ships promptly with an
|
||||||
|
/// honest commitment at the difficulty actually reached. Returns nil only
|
||||||
|
/// if canonical serialization fails; the caller then sends unmined.
|
||||||
|
static func mineNonceTag(
|
||||||
|
pubkey: String,
|
||||||
|
createdAt: Int,
|
||||||
|
kind: Int,
|
||||||
|
tags: [[String]],
|
||||||
|
content: String,
|
||||||
|
targetBits: Int = NostrPoW.targetBits
|
||||||
|
) async -> [String]? {
|
||||||
|
var target = min(max(targetBits, 0), 256)
|
||||||
|
var budget = miningTimeCap
|
||||||
|
while true {
|
||||||
|
if let tag = mineAttempt(
|
||||||
|
pubkey: pubkey,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: kind,
|
||||||
|
baseTags: tags,
|
||||||
|
content: content,
|
||||||
|
targetBits: target,
|
||||||
|
budget: budget
|
||||||
|
) {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
// Target 0 succeeds on the first hash, so reaching it with nil
|
||||||
|
// means serialization itself failed — give up on mining.
|
||||||
|
if target == 0 { return nil }
|
||||||
|
target /= 2
|
||||||
|
budget = fallbackTimeCap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
||||||
|
/// the canonical serialization is built once and only the fixed-width
|
||||||
|
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
||||||
|
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
||||||
|
/// the template could not be built.
|
||||||
|
private static func mineAttempt(
|
||||||
|
pubkey: String,
|
||||||
|
createdAt: Int,
|
||||||
|
kind: Int,
|
||||||
|
baseTags: [[String]],
|
||||||
|
content: String,
|
||||||
|
targetBits: Int,
|
||||||
|
budget: TimeInterval
|
||||||
|
) -> [String]? {
|
||||||
|
let targetString = String(targetBits)
|
||||||
|
guard let template = serializedTemplate(
|
||||||
|
pubkey: pubkey,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: kind,
|
||||||
|
baseTags: baseTags,
|
||||||
|
content: content,
|
||||||
|
targetString: targetString
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var buffer = template.buffer
|
||||||
|
let nonceRange = template.nonceRange
|
||||||
|
|
||||||
|
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
||||||
|
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
||||||
|
var nonce = UInt64.random(in: .min ... .max)
|
||||||
|
var attempts: UInt64 = 0
|
||||||
|
|
||||||
|
while true {
|
||||||
|
// Write the nonce as 16 lowercase hex chars, in place.
|
||||||
|
var value = nonce
|
||||||
|
var index = nonceRange.upperBound
|
||||||
|
while index > nonceRange.lowerBound {
|
||||||
|
index -= 1
|
||||||
|
buffer[index] = hexDigits[Int(value & 0xF)]
|
||||||
|
value >>= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
||||||
|
// Identical to the bytes just written into the buffer.
|
||||||
|
return ["nonce", String(format: "%016llx", nonce), targetString]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce &+= 1
|
||||||
|
attempts &+= 1
|
||||||
|
if attempts % checkInterval == 0,
|
||||||
|
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
||||||
|
/// plus the byte range of the nonce value inside it.
|
||||||
|
///
|
||||||
|
/// The range is located by serializing twice with two same-length
|
||||||
|
/// placeholders and diffing the buffers — the only differing bytes are
|
||||||
|
/// the nonce value, so this stays correct however `JSONSerialization`
|
||||||
|
/// escapes the surrounding fields (and even if the content contains the
|
||||||
|
/// placeholder text itself).
|
||||||
|
private static func serializedTemplate(
|
||||||
|
pubkey: String,
|
||||||
|
createdAt: Int,
|
||||||
|
kind: Int,
|
||||||
|
baseTags: [[String]],
|
||||||
|
content: String,
|
||||||
|
targetString: String
|
||||||
|
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
||||||
|
func serialize(noncePlaceholder: String) -> Data? {
|
||||||
|
var tags = baseTags
|
||||||
|
tags.append(["nonce", noncePlaceholder, targetString])
|
||||||
|
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||||
|
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
||||||
|
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
||||||
|
zeros.count == effs.count
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstDiff = -1
|
||||||
|
var lastDiff = -1
|
||||||
|
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
||||||
|
if firstDiff < 0 { firstDiff = index }
|
||||||
|
lastDiff = index
|
||||||
|
}
|
||||||
|
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
||||||
|
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ struct NostrProtocol {
|
|||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
|
case deletion = 5 // NIP-09 event deletion request
|
||||||
|
/// Sealed courier envelope parked on relays under its rotating
|
||||||
|
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
||||||
|
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||||
|
case courierDrop = 1401
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -170,6 +175,63 @@ struct NostrProtocol {
|
|||||||
nickname: String? = nil,
|
nickname: String? = nil,
|
||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
||||||
|
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
||||||
|
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
||||||
|
/// surrounding task is cancelled) the event ships at the highest
|
||||||
|
/// committed difficulty still met, and if mining is impossible it ships
|
||||||
|
/// unmined — sending is never blocked.
|
||||||
|
static func createMinedEphemeralGeohashEvent(
|
||||||
|
content: String,
|
||||||
|
geohash: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil,
|
||||||
|
teleported: Bool = false,
|
||||||
|
powTargetBits: Int = NostrPoW.targetBits
|
||||||
|
) async throws -> NostrEvent {
|
||||||
|
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
||||||
|
// Fix created_at up front: the mined nonce commits to the full
|
||||||
|
// serialized event, so the signed event must reuse the exact value.
|
||||||
|
let createdAt = Int(Date().timeIntervalSince1970)
|
||||||
|
if let nonceTag = await NostrPoW.mineNonceTag(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: EventKind.ephemeralEvent.rawValue,
|
||||||
|
tags: tags,
|
||||||
|
content: content,
|
||||||
|
targetBits: powTargetBits
|
||||||
|
) {
|
||||||
|
tags.append(nonceTag)
|
||||||
|
}
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: tags,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
||||||
|
/// variants).
|
||||||
|
private static func ephemeralGeohashTags(
|
||||||
|
geohash: String,
|
||||||
|
nickname: String?,
|
||||||
|
teleported: Bool
|
||||||
|
) -> [[String]] {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
@@ -177,15 +239,7 @@ struct NostrProtocol {
|
|||||||
if teleported {
|
if teleported {
|
||||||
tags.append(["t", "teleport"])
|
tags.append(["t", "teleport"])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
return tags
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
/// Create a geohash presence heartbeat (kind 20001)
|
||||||
@@ -206,17 +260,105 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh bridge (rendezvous) events
|
||||||
|
|
||||||
|
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
||||||
|
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
||||||
|
/// channel subscriptions (which filter on `#g`); `m` carries the original
|
||||||
|
/// mesh message ID so receivers dedup the bridged copy against the radio
|
||||||
|
/// copy by timeline ID.
|
||||||
|
static func createBridgeMeshEvent(
|
||||||
|
content: String,
|
||||||
|
cell: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil,
|
||||||
|
meshMessageID: String? = nil
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
var tags = [["r", cell]]
|
||||||
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
|
tags.append(["n", nickname])
|
||||||
|
}
|
||||||
|
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
|
||||||
|
tags.append(["m", meshMessageID])
|
||||||
|
}
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: tags,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
|
||||||
|
/// cell: empty content, `r` tag only — the bridge analogue of geohash
|
||||||
|
/// presence, counted into "people across the bridge".
|
||||||
|
static func createBridgePresenceEvent(
|
||||||
|
cell: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .geohashPresence,
|
||||||
|
tags: [["r", cell]],
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
|
||||||
|
/// parked on relays. `x` is the hex recipient tag the recipient (or a
|
||||||
|
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
|
||||||
|
/// the envelope expiry so honoring relays garbage-collect the drop. The
|
||||||
|
/// signing identity should be a throwaway — the envelope authenticates
|
||||||
|
/// its sender internally via Noise-X, and linking drops to a stable
|
||||||
|
/// publisher key would leak courier traffic patterns.
|
||||||
|
static func createCourierDropEvent(
|
||||||
|
envelope: Data,
|
||||||
|
recipientTagHex: String,
|
||||||
|
expiresAt: Date,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let tags = [
|
||||||
|
["x", recipientTagHex],
|
||||||
|
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
|
||||||
|
]
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .courierDrop,
|
||||||
|
tags: tags,
|
||||||
|
content: envelope.base64EncodedString()
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
|
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
||||||
|
/// drop the note in step with a bridged board post's expiry.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil
|
nickname: String? = nil,
|
||||||
|
expiresAt: Date? = nil,
|
||||||
|
urgent: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
|
if let expiresAt {
|
||||||
|
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
||||||
|
}
|
||||||
|
if urgent {
|
||||||
|
tags.append(["t", "urgent"])
|
||||||
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -227,7 +369,25 @@ struct NostrProtocol {
|
|||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
||||||
|
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
||||||
|
/// key that signed the original.
|
||||||
|
static func createDeleteEvent(
|
||||||
|
ofEventID eventID: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .deletion,
|
||||||
|
tags: [["e", eventID]],
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
@@ -474,37 +634,6 @@ struct NostrProtocol {
|
|||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct version that doesn't try to add prefixes
|
|
||||||
private static func deriveSharedSecretDirect(
|
|
||||||
privateKey: P256K.Schnorr.PrivateKey,
|
|
||||||
publicKey: Data
|
|
||||||
) throws -> Data {
|
|
||||||
// Direct shared secret calculation
|
|
||||||
|
|
||||||
// Convert Schnorr private key to KeyAgreement private key
|
|
||||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
|
||||||
dataRepresentation: privateKey.dataRepresentation
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use the public key as-is (should already have prefix)
|
|
||||||
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
|
||||||
dataRepresentation: publicKey,
|
|
||||||
format: .compressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Perform ECDH
|
|
||||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
|
||||||
with: keyAgreementPublicKey,
|
|
||||||
format: .compressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert SharedSecret to Data
|
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
|
||||||
|
|
||||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
|
||||||
return sharedSecretData
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Add random offset to current time for privacy
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
// This prevents timing correlation attacks while the actual message timestamp
|
||||||
@@ -634,11 +763,8 @@ struct NostrEvent: Codable {
|
|||||||
|
|
||||||
enum NostrError: Error {
|
enum NostrError: Error {
|
||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidPrivateKey
|
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case signingFailed
|
|
||||||
case encryptionFailed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
let url: String
|
let url: String
|
||||||
var isConnected: Bool = false
|
var isConnected: Bool = false
|
||||||
var lastError: Error?
|
var lastError: Error?
|
||||||
var lastConnectedAt: Date?
|
|
||||||
var messagesSent: Int = 0
|
var messagesSent: Int = 0
|
||||||
var messagesReceived: Int = 0
|
var messagesReceived: Int = 0
|
||||||
var reconnectAttempts: Int = 0
|
var reconnectAttempts: Int = 0
|
||||||
@@ -184,11 +183,26 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||||
|
|
||||||
// Track EOSE per subscription to signal when initial stored events are done
|
// Track EOSE per subscription to signal when initial stored events are
|
||||||
|
// done. Completion is scoped to relays the REQ actually reached: targets
|
||||||
|
// still mid-connect must not hold the callback hostage until the fallback
|
||||||
|
// timer (a dead relay of five used to pin "loading" for the full 10s).
|
||||||
private struct EOSETracker {
|
private struct EOSETracker {
|
||||||
var pendingRelays: Set<String>
|
/// Targets the REQ has not been delivered to yet (still connecting).
|
||||||
|
var awaitingSend: Set<String>
|
||||||
|
/// Relays that received the REQ and have not sent EOSE yet.
|
||||||
|
var awaitingEOSE: Set<String>
|
||||||
|
/// True once any relay received the REQ (or answered with EOSE) —
|
||||||
|
/// completion with zero sends would mean "done" without ever asking.
|
||||||
|
var didSend = false
|
||||||
var callback: () -> Void
|
var callback: () -> Void
|
||||||
let epoch: Int
|
let epoch: Int
|
||||||
|
|
||||||
|
/// Done when every relay that got the REQ has resolved, provided at
|
||||||
|
/// least one did — or when every target dropped out entirely.
|
||||||
|
var isComplete: Bool {
|
||||||
|
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
private var eoseTrackerEpoch = 0
|
private var eoseTrackerEpoch = 0
|
||||||
@@ -347,7 +361,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
if resetState {
|
if resetState {
|
||||||
relays[index].lastError = nil
|
relays[index].lastError = nil
|
||||||
relays[index].lastConnectedAt = nil
|
|
||||||
relays[index].lastDisconnectedAt = nil
|
relays[index].lastDisconnectedAt = nil
|
||||||
relays[index].messagesSent = 0
|
relays[index].messagesSent = 0
|
||||||
relays[index].messagesReceived = 0
|
relays[index].messagesReceived = 0
|
||||||
@@ -795,7 +808,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||||
eoseTrackerEpoch += 1
|
eoseTrackerEpoch += 1
|
||||||
let epoch = eoseTrackerEpoch
|
let epoch = eoseTrackerEpoch
|
||||||
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
|
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||||
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -935,8 +948,19 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
toSend[id] = state.messageString
|
toSend[id] = state.messageString
|
||||||
}
|
}
|
||||||
for (id, messageString) in toSend {
|
for (id, messageString) in toSend {
|
||||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
if self.subscriptions[relayUrl]?.contains(id) == true {
|
||||||
|
// Already subscribed on this relay (e.g. a tracker promoted
|
||||||
|
// after an earlier flush): its EOSE is coming, count it.
|
||||||
|
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
startPendingEOSETrackingIfNeeded(id: id)
|
startPendingEOSETrackingIfNeeded(id: id)
|
||||||
|
// Mark at send *initiation*, not in the async completion: a fast
|
||||||
|
// relay's EOSE could otherwise complete the tracker while this
|
||||||
|
// relay — REQ already on the wire — still sat in awaitingSend.
|
||||||
|
// If the send fails the socket is going down with it, and the
|
||||||
|
// disconnect settle (or the fallback timer) releases the wait.
|
||||||
|
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||||
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -1018,8 +1042,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
case .eose(let subId):
|
case .eose(let subId):
|
||||||
if var tracker = eoseTrackers[subId] {
|
if var tracker = eoseTrackers[subId] {
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
// An EOSE proves the relay received the REQ even if the local
|
||||||
if tracker.pendingRelays.isEmpty {
|
// send completion hasn't run yet.
|
||||||
|
tracker.awaitingSend.remove(relayUrl)
|
||||||
|
tracker.awaitingEOSE.remove(relayUrl)
|
||||||
|
tracker.didSend = true
|
||||||
|
if tracker.isComplete {
|
||||||
eoseTrackers.removeValue(forKey: subId)
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1075,7 +1103,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].isConnected = isConnected
|
relays[index].isConnected = isConnected
|
||||||
relays[index].lastError = error
|
relays[index].lastError = error
|
||||||
if isConnected {
|
if isConnected {
|
||||||
relays[index].lastConnectedAt = dependencies.now()
|
|
||||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
} else {
|
} else {
|
||||||
@@ -1100,9 +1127,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// callbacks; treat it as done and let the remaining relays (or the
|
/// callbacks; treat it as done and let the remaining relays (or the
|
||||||
/// fallback timeout) drive completion.
|
/// fallback timeout) drive completion.
|
||||||
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
||||||
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
|
for (id, var tracker) in eoseTrackers
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
|
||||||
if tracker.pendingRelays.isEmpty {
|
tracker.awaitingSend.remove(relayUrl)
|
||||||
|
tracker.awaitingEOSE.remove(relayUrl)
|
||||||
|
if tracker.isComplete {
|
||||||
eoseTrackers.removeValue(forKey: id)
|
eoseTrackers.removeValue(forKey: id)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1111,6 +1140,24 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any of `relayUrls` currently holds a live connection. Lets
|
||||||
|
/// subscribers distinguish "loaded, empty" from "never reached a relay"
|
||||||
|
/// when an EOSE fallback fires.
|
||||||
|
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
|
||||||
|
let targets = Set(relayUrls)
|
||||||
|
return relays.contains { targets.contains($0.url) && $0.isConnected }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
|
||||||
|
/// this relay instead of the never-connected remainder.
|
||||||
|
private func markEOSESubscribed(id: String, relayUrl: String) {
|
||||||
|
guard var tracker = eoseTrackers[id],
|
||||||
|
tracker.awaitingSend.remove(relayUrl) != nil else { return }
|
||||||
|
tracker.awaitingEOSE.insert(relayUrl)
|
||||||
|
tracker.didSend = true
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
|
}
|
||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
@@ -1463,6 +1510,29 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the mesh bridge: rendezvous messages (kind 20000) and presence
|
||||||
|
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
|
||||||
|
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [20000, 20001]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["r": cells]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// For courier drops: sealed envelopes (kind 1401) parked under rotating
|
||||||
|
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
|
||||||
|
// UTC days x recipients) in one filter.
|
||||||
|
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["x": recipientTagsHex]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ struct BitchatFilePacket {
|
|||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
func encode() -> Data? {
|
||||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
|
||||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
var big = value.bigEndian
|
var big = value.bigEndian
|
||||||
@@ -68,9 +66,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
|
||||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
|
||||||
var cursor = data.startIndex
|
var cursor = data.startIndex
|
||||||
let end = data.endIndex
|
let end = data.endIndex
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ struct BitchatFilePacket {
|
|||||||
for byte in value {
|
for byte in value {
|
||||||
size = (size << 8) | UInt64(byte)
|
size = (size << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
if size > UInt64(limit) {
|
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fileSize = size
|
fileSize = size
|
||||||
@@ -139,7 +135,7 @@ struct BitchatFilePacket {
|
|||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
let proposedSize = content.count + value.count
|
let proposedSize = content.count + value.count
|
||||||
if proposedSize > limit {
|
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.append(contentsOf: value)
|
content.append(contentsOf: value)
|
||||||
@@ -149,7 +145,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
guard !content.isEmpty else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
return BitchatFilePacket(
|
return BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
/// - Efficient binary message encoding
|
/// - Efficient binary message encoding
|
||||||
/// - Message fragmentation for large payloads
|
/// - Message fragmentation for large payloads
|
||||||
/// - TTL-based routing for mesh networks
|
/// - TTL-based routing for mesh networks
|
||||||
/// - Privacy features like padding and timing obfuscation
|
/// - Privacy features: message padding and randomized relay jitter
|
||||||
/// - Integration points for end-to-end encryption
|
/// - Integration points for end-to-end encryption
|
||||||
///
|
///
|
||||||
/// ## Protocol Design
|
/// ## Protocol Design
|
||||||
@@ -38,18 +38,20 @@
|
|||||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||||
///
|
///
|
||||||
/// ## Security Considerations
|
/// ## Security Considerations
|
||||||
/// - Message padding obscures actual content length
|
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||||
/// - Timing obfuscation prevents traffic analysis
|
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
||||||
|
/// cover traffic or per-message timing obfuscation
|
||||||
/// - Integration with Noise Protocol for E2E encryption
|
/// - Integration with Noise Protocol for E2E encryption
|
||||||
/// - No persistent identifiers in protocol headers
|
/// - No persistent identifiers in protocol headers
|
||||||
///
|
///
|
||||||
/// ## Message Types
|
/// ## Message Types
|
||||||
/// - **Announce/Leave**: Peer presence notifications
|
/// - **Announce/Leave**: Peer presence notifications
|
||||||
/// - **Message**: User chat messages (broadcast or directed)
|
/// - **Message**: Public chat messages
|
||||||
/// - **Fragment**: Multi-part message handling
|
/// - **Fragment**: Multi-part message handling
|
||||||
/// - **Delivery/Read**: Message acknowledgments
|
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
||||||
/// - **Noise**: Encrypted channel establishment
|
/// all private payloads (messages, delivery acks, read receipts)
|
||||||
/// - **Version**: Protocol version negotiation
|
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
||||||
|
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
|
||||||
///
|
///
|
||||||
/// ## Future Extensions
|
/// ## Future Extensions
|
||||||
/// The protocol is designed to be extensible:
|
/// The protocol is designed to be extensible:
|
||||||
@@ -72,22 +74,28 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
// Private groups (0x04/0x05 reserved by other features)
|
||||||
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||||
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
|
// Live voice (push-to-talk)
|
||||||
|
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
|
// Transitive verification (web of trust)
|
||||||
|
case vouch = 0x12 // Batch of vouch attestations
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
case .groupInvite: return "groupInvite"
|
||||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
|
case .voiceFrame: return "voiceFrame"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
|
case .vouch: return "vouch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +127,12 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
|
|
||||||
|
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||||
|
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
||||||
|
|
||||||
|
// Public live-voice burst packet (signature-verified by the transport)
|
||||||
|
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
|
||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
@@ -138,6 +152,14 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
||||||
|
// Default empty implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
|
||||||
|
// Default empty implementation
|
||||||
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//
|
||||||
|
// BoardPackets.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Board wire format (MessageType.boardPost payloads)
|
||||||
|
//
|
||||||
|
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
||||||
|
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
||||||
|
// - 0x02: postID (16B random)
|
||||||
|
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
||||||
|
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
||||||
|
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
||||||
|
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
||||||
|
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
||||||
|
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
||||||
|
// - 0x09: flags (u8, bit0 = urgent) [post]
|
||||||
|
// - 0x0A: signature (64B Ed25519)
|
||||||
|
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
||||||
|
// Unknown TLVs are skipped for forward compatibility.
|
||||||
|
|
||||||
|
enum BoardWireConstants {
|
||||||
|
static let postIDLength = 16
|
||||||
|
static let signingKeyLength = 32
|
||||||
|
static let signatureLength = 64
|
||||||
|
static let contentMaxBytes = 512
|
||||||
|
static let nicknameMaxBytes = 64
|
||||||
|
static let geohashMaxLength = 12
|
||||||
|
/// Posts may live at most 7 days past their creation timestamp.
|
||||||
|
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
||||||
|
static let postSigningContext = "bitchat-board-v1"
|
||||||
|
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
||||||
|
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BoardTLVType: UInt8 {
|
||||||
|
case kind = 0x01
|
||||||
|
case postID = 0x02
|
||||||
|
case geohash = 0x03
|
||||||
|
case content = 0x04
|
||||||
|
case authorSigningKey = 0x05
|
||||||
|
case authorNickname = 0x06
|
||||||
|
case createdAt = 0x07
|
||||||
|
case expiresAt = 0x08
|
||||||
|
case flags = 0x09
|
||||||
|
case signature = 0x0A
|
||||||
|
case deletedAt = 0x0B
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BoardWireKind: UInt8 {
|
||||||
|
case post = 0x01
|
||||||
|
case tombstone = 0x02
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed, persistent bulletin-board notice.
|
||||||
|
struct BoardPostPacket: Equatable {
|
||||||
|
let postID: Data
|
||||||
|
/// Empty string scopes the post to the mesh-local board.
|
||||||
|
let geohash: String
|
||||||
|
let content: String
|
||||||
|
let authorSigningKey: Data
|
||||||
|
let authorNickname: String
|
||||||
|
let createdAt: UInt64
|
||||||
|
let expiresAt: UInt64
|
||||||
|
let flags: UInt8
|
||||||
|
let signature: Data
|
||||||
|
|
||||||
|
static let urgentFlag: UInt8 = 0x01
|
||||||
|
|
||||||
|
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
||||||
|
|
||||||
|
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
||||||
|
/// fields are length-prefixed so no two field combinations can collide.
|
||||||
|
static func signingBytes(
|
||||||
|
postID: Data,
|
||||||
|
geohash: String,
|
||||||
|
content: String,
|
||||||
|
authorSigningKey: Data,
|
||||||
|
authorNickname: String,
|
||||||
|
createdAt: UInt64,
|
||||||
|
expiresAt: UInt64,
|
||||||
|
flags: UInt8
|
||||||
|
) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
||||||
|
out.append(postID)
|
||||||
|
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
||||||
|
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
||||||
|
out.append(authorSigningKey)
|
||||||
|
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
||||||
|
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
||||||
|
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
||||||
|
out.append(flags)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingBytes: Data {
|
||||||
|
Self.signingBytes(
|
||||||
|
postID: postID,
|
||||||
|
geohash: geohash,
|
||||||
|
content: content,
|
||||||
|
authorSigningKey: authorSigningKey,
|
||||||
|
authorNickname: authorNickname,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
flags: flags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature() -> Bool {
|
||||||
|
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed deletion marker. Only the author's key can produce one; receivers
|
||||||
|
/// keep it until the post's original expiry so the delete outruns the post.
|
||||||
|
struct BoardTombstonePacket: Equatable {
|
||||||
|
let postID: Data
|
||||||
|
let authorSigningKey: Data
|
||||||
|
let deletedAt: UInt64
|
||||||
|
let signature: Data
|
||||||
|
|
||||||
|
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
||||||
|
out.append(postID)
|
||||||
|
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingBytes: Data {
|
||||||
|
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature() -> Bool {
|
||||||
|
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded board payload: either a live post or a tombstone.
|
||||||
|
enum BoardWire: Equatable {
|
||||||
|
case post(BoardPostPacket)
|
||||||
|
case tombstone(BoardTombstonePacket)
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
||||||
|
out.append(t.rawValue)
|
||||||
|
let len = UInt16(v.count)
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(v)
|
||||||
|
}
|
||||||
|
switch self {
|
||||||
|
case .post(let post):
|
||||||
|
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
||||||
|
putTLV(.postID, post.postID)
|
||||||
|
putTLV(.geohash, Data(post.geohash.utf8))
|
||||||
|
putTLV(.content, Data(post.content.utf8))
|
||||||
|
putTLV(.authorSigningKey, post.authorSigningKey)
|
||||||
|
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
||||||
|
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
||||||
|
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
||||||
|
putTLV(.flags, Data([post.flags]))
|
||||||
|
putTLV(.signature, post.signature)
|
||||||
|
case .tombstone(let tombstone):
|
||||||
|
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
||||||
|
putTLV(.postID, tombstone.postID)
|
||||||
|
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
||||||
|
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
||||||
|
putTLV(.signature, tombstone.signature)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structural decode; the caller must still verify the signature before
|
||||||
|
/// ingesting (`verifySignature()`).
|
||||||
|
static func decode(from data: Data) -> BoardWire? {
|
||||||
|
var off = data.startIndex
|
||||||
|
var kind: BoardWireKind?
|
||||||
|
var postID: Data?
|
||||||
|
var geohash: String?
|
||||||
|
var content: String?
|
||||||
|
var contentBytes = 0
|
||||||
|
var authorSigningKey: Data?
|
||||||
|
var authorNickname: String?
|
||||||
|
var nicknameBytes = 0
|
||||||
|
var createdAt: UInt64?
|
||||||
|
var expiresAt: UInt64?
|
||||||
|
var flags: UInt8?
|
||||||
|
var signature: Data?
|
||||||
|
var deletedAt: UInt64?
|
||||||
|
|
||||||
|
while off + 3 <= data.endIndex {
|
||||||
|
let t = data[off]; off += 1
|
||||||
|
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||||
|
guard off + len <= data.endIndex else { return nil }
|
||||||
|
let v = data.subdata(in: off..<(off + len)); off += len
|
||||||
|
switch BoardTLVType(rawValue: t) {
|
||||||
|
case .kind:
|
||||||
|
guard v.count == 1 else { return nil }
|
||||||
|
kind = BoardWireKind(rawValue: v[v.startIndex])
|
||||||
|
case .postID:
|
||||||
|
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
||||||
|
postID = v
|
||||||
|
case .geohash:
|
||||||
|
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
||||||
|
geohash = String(data: v, encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
||||||
|
contentBytes = v.count
|
||||||
|
content = String(data: v, encoding: .utf8)
|
||||||
|
case .authorSigningKey:
|
||||||
|
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
||||||
|
authorSigningKey = v
|
||||||
|
case .authorNickname:
|
||||||
|
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
||||||
|
nicknameBytes = v.count
|
||||||
|
authorNickname = String(data: v, encoding: .utf8)
|
||||||
|
case .createdAt:
|
||||||
|
createdAt = BoardWireEncoding.uint64(from: v)
|
||||||
|
case .expiresAt:
|
||||||
|
expiresAt = BoardWireEncoding.uint64(from: v)
|
||||||
|
case .flags:
|
||||||
|
guard v.count == 1 else { return nil }
|
||||||
|
flags = v[v.startIndex]
|
||||||
|
case .signature:
|
||||||
|
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
||||||
|
signature = v
|
||||||
|
case .deletedAt:
|
||||||
|
deletedAt = BoardWireEncoding.uint64(from: v)
|
||||||
|
case nil:
|
||||||
|
continue // forward compatible; ignore unknown TLVs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let postID, let authorSigningKey, let signature else { return nil }
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case .post:
|
||||||
|
guard let geohash, let content, let authorNickname,
|
||||||
|
let createdAt, let expiresAt, let flags,
|
||||||
|
contentBytes >= 1,
|
||||||
|
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
||||||
|
isValidGeohashField(geohash),
|
||||||
|
expiresAt > createdAt,
|
||||||
|
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .post(BoardPostPacket(
|
||||||
|
postID: postID,
|
||||||
|
geohash: geohash,
|
||||||
|
content: content,
|
||||||
|
authorSigningKey: authorSigningKey,
|
||||||
|
authorNickname: authorNickname,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
flags: flags,
|
||||||
|
signature: signature
|
||||||
|
))
|
||||||
|
case .tombstone:
|
||||||
|
guard let deletedAt else { return nil }
|
||||||
|
return .tombstone(BoardTombstonePacket(
|
||||||
|
postID: postID,
|
||||||
|
authorSigningKey: authorSigningKey,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
signature: signature
|
||||||
|
))
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature() -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .post(let post): return post.verifySignature()
|
||||||
|
case .tombstone(let tombstone): return tombstone.verifySignature()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
||||||
|
/// Avoids a full decode on the hot relay path.
|
||||||
|
static func urgentFlag(in data: Data) -> Bool {
|
||||||
|
var off = data.startIndex
|
||||||
|
while off + 3 <= data.endIndex {
|
||||||
|
let t = data[off]; off += 1
|
||||||
|
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||||
|
guard off + len <= data.endIndex else { return false }
|
||||||
|
if t == BoardTLVType.flags.rawValue, len == 1 {
|
||||||
|
return data[off] & BoardPostPacket.urgentFlag != 0
|
||||||
|
}
|
||||||
|
off += len
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
||||||
|
/// base32 alphabet.
|
||||||
|
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
||||||
|
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BoardWireEncoding {
|
||||||
|
static func appendContext(_ context: String, to out: inout Data) {
|
||||||
|
let bytes = Data(context.utf8)
|
||||||
|
out.append(UInt8(min(bytes.count, 255)))
|
||||||
|
out.append(bytes.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
||||||
|
let len = UInt16(min(value.count, Int(UInt16.max)))
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(value.prefix(Int(UInt16.max)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
||||||
|
var be = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func uint64Data(_ value: UInt64) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
appendUInt64(value, to: &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func uint64(from data: Data) -> UInt64? {
|
||||||
|
guard data.count == 8 else { return nil }
|
||||||
|
var value: UInt64 = 0
|
||||||
|
for byte in data { value = (value << 8) | UInt64(byte) }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
||||||
|
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return key.isValidSignature(signature, for: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,11 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Validates a geohash string for building-level precision (8 characters).
|
/// Validates a geohash string at any channel precision (1-12 characters).
|
||||||
/// - Parameter geohash: The geohash string to validate
|
/// - Parameter geohash: The geohash string to validate
|
||||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||||
guard geohash.count == 8 else { return false }
|
guard (1...12).contains(geohash.count) else { return false }
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// NostrCarrierPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
|
||||||
|
/// Nostr event ferried over the mesh between a mesh-only peer and an
|
||||||
|
/// internet gateway peer.
|
||||||
|
///
|
||||||
|
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
|
||||||
|
/// a mesh-only sender asks the gateway to publish its locally signed
|
||||||
|
/// geohash event to Nostr relays.
|
||||||
|
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
|
||||||
|
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
|
||||||
|
///
|
||||||
|
/// The carried event is public geohash chat — already plaintext on Nostr —
|
||||||
|
/// so the carrier adds no encryption. It IS signed by the originator's
|
||||||
|
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
|
||||||
|
/// or alter it undetected: gateways and receivers verify the Schnorr
|
||||||
|
/// signature before acting on it.
|
||||||
|
///
|
||||||
|
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
|
||||||
|
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
|
||||||
|
/// for forward compatibility.
|
||||||
|
struct NostrCarrierPacket: Equatable {
|
||||||
|
enum Direction: UInt8 {
|
||||||
|
case toGateway = 0x01
|
||||||
|
case fromGateway = 0x02
|
||||||
|
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
|
||||||
|
/// publish its signed rendezvous event. Directed, like `toGateway`.
|
||||||
|
case toBridge = 0x03
|
||||||
|
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
|
||||||
|
/// event from a remote island. Broadcast, like `fromGateway`.
|
||||||
|
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
|
||||||
|
/// carrier quietly — bridge traffic degrades to invisible, not junk.
|
||||||
|
case fromBridge = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction: Direction
|
||||||
|
let geohash: String
|
||||||
|
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
|
||||||
|
/// content, sig).
|
||||||
|
let eventJSON: Data
|
||||||
|
|
||||||
|
/// BLE airtime cap for a carried event.
|
||||||
|
static let maxEventJSONBytes = 16 * 1024
|
||||||
|
static let maxGeohashLength = 12
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case direction = 0x01
|
||||||
|
case geohash = 0x02
|
||||||
|
case eventJSON = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(direction: Direction, geohash: String, eventJSON: Data) {
|
||||||
|
let geohashBytes = Data(geohash.utf8)
|
||||||
|
guard !geohashBytes.isEmpty,
|
||||||
|
geohashBytes.count <= Self.maxGeohashLength,
|
||||||
|
!eventJSON.isEmpty,
|
||||||
|
eventJSON.count <= Self.maxEventJSONBytes else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.direction = direction
|
||||||
|
self.geohash = geohash
|
||||||
|
self.eventJSON = eventJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(direction: Direction, geohash: String, event: NostrEvent) {
|
||||||
|
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
|
||||||
|
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the carried event. Callers MUST still verify
|
||||||
|
/// `event.isValidSignature()` before publishing or displaying it.
|
||||||
|
func event() -> NostrEvent? {
|
||||||
|
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? NostrEvent(from: dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
|
||||||
|
|
||||||
|
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||||
|
data.append(type.rawValue)
|
||||||
|
data.append(UInt8((value.count >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value.count & 0xFF))
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTLV(.direction, Data([direction.rawValue]))
|
||||||
|
appendTLV(.geohash, Data(geohash.utf8))
|
||||||
|
appendTLV(.eventJSON, eventJSON)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> NostrCarrierPacket? {
|
||||||
|
// Defensive slice re-base (Data slices keep parent indices).
|
||||||
|
let data = Data(data)
|
||||||
|
var offset = 0
|
||||||
|
var direction: Direction?
|
||||||
|
var geohash: String?
|
||||||
|
var eventJSON: Data?
|
||||||
|
|
||||||
|
while offset + 3 <= data.count {
|
||||||
|
let typeRaw = data[offset]
|
||||||
|
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
|
||||||
|
offset += 3
|
||||||
|
guard offset + length <= data.count else { return nil }
|
||||||
|
let value = data.subdata(in: offset..<offset + length)
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
switch TLVType(rawValue: typeRaw) {
|
||||||
|
case .direction:
|
||||||
|
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
|
||||||
|
direction = parsed
|
||||||
|
case .geohash:
|
||||||
|
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
|
||||||
|
geohash = parsed
|
||||||
|
case .eventJSON:
|
||||||
|
eventJSON = value
|
||||||
|
case nil:
|
||||||
|
// Unknown TLV; skip (tolerant decoder for forward compatibility).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard offset == data.count,
|
||||||
|
let direction,
|
||||||
|
let geohash,
|
||||||
|
let eventJSON else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,19 +9,25 @@ struct AnnouncementPacket {
|
|||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||||
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||||
|
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
|
||||||
|
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
|
||||||
|
/// tagged rendezvous events without their own location fix.
|
||||||
|
let bridgeGeohash: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
nickname: String,
|
nickname: String,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
signingPublicKey: Data,
|
signingPublicKey: Data,
|
||||||
directNeighbors: [Data]?,
|
directNeighbors: [Data]?,
|
||||||
capabilities: PeerCapabilities? = nil
|
capabilities: PeerCapabilities? = nil,
|
||||||
|
bridgeGeohash: String? = nil
|
||||||
) {
|
) {
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.signingPublicKey = signingPublicKey
|
self.signingPublicKey = signingPublicKey
|
||||||
self.directNeighbors = directNeighbors
|
self.directNeighbors = directNeighbors
|
||||||
self.capabilities = capabilities
|
self.capabilities = capabilities
|
||||||
|
self.bridgeGeohash = bridgeGeohash
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
@@ -30,6 +36,7 @@ struct AnnouncementPacket {
|
|||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
case directNeighbors = 0x04
|
||||||
case capabilities = 0x05
|
case capabilities = 0x05
|
||||||
|
case bridgeGeohash = 0x06
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -74,6 +81,15 @@ struct AnnouncementPacket {
|
|||||||
data.append(capabilityBytes)
|
data.append(capabilityBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLV for bridge rendezvous cell (optional; old clients skip it)
|
||||||
|
if let bridgeGeohash = bridgeGeohash,
|
||||||
|
let cellData = bridgeGeohash.data(using: .utf8),
|
||||||
|
!cellData.isEmpty, cellData.count <= 12 {
|
||||||
|
data.append(TLVType.bridgeGeohash.rawValue)
|
||||||
|
data.append(UInt8(cellData.count))
|
||||||
|
data.append(cellData)
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +100,7 @@ struct AnnouncementPacket {
|
|||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
var capabilities: PeerCapabilities?
|
var capabilities: PeerCapabilities?
|
||||||
|
var bridgeGeohash: String?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -116,6 +133,10 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
case .capabilities:
|
case .capabilities:
|
||||||
capabilities = PeerCapabilities(encoded: Data(value))
|
capabilities = PeerCapabilities(encoded: Data(value))
|
||||||
|
case .bridgeGeohash:
|
||||||
|
if length <= 12 {
|
||||||
|
bridgeGeohash = String(data: value, encoding: .utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -129,7 +150,8 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors,
|
directNeighbors: directNeighbors,
|
||||||
capabilities: capabilities
|
capabilities: capabilities,
|
||||||
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
|
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// VoiceBurstPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Audio codec of a live voice burst. START packets carry it so receivers can
|
||||||
|
/// reject bursts they can't decode instead of feeding garbage to the decoder.
|
||||||
|
enum VoiceBurstCodec: UInt8 {
|
||||||
|
/// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so
|
||||||
|
/// the finalized `.m4a` and the live frames come from the same encoder
|
||||||
|
/// settings.
|
||||||
|
case aacLC16kMono = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One packet of a live push-to-talk voice burst (the inner payload of
|
||||||
|
/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload
|
||||||
|
/// of `MessageType.voiceFrame`).
|
||||||
|
///
|
||||||
|
/// Wire format:
|
||||||
|
/// ```
|
||||||
|
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…]
|
||||||
|
/// ```
|
||||||
|
/// - flags 0x01 (START): payload = [codec: UInt8]
|
||||||
|
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
|
||||||
|
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
|
||||||
|
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
|
||||||
|
struct VoiceBurstPacket: Equatable {
|
||||||
|
enum Kind: Equatable {
|
||||||
|
case start(codec: VoiceBurstCodec)
|
||||||
|
case frames([Data])
|
||||||
|
case end(totalDataPackets: UInt16, durationMs: UInt32)
|
||||||
|
case canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
static let burstIDSize = 8
|
||||||
|
private static let headerSize = burstIDSize + 2 + 1
|
||||||
|
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
|
||||||
|
static let maxFramesPerPacket = 8
|
||||||
|
|
||||||
|
private enum Flags {
|
||||||
|
static let start: UInt8 = 0x01
|
||||||
|
static let end: UInt8 = 0x02
|
||||||
|
static let canceled: UInt8 = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
let burstID: Data
|
||||||
|
let seq: UInt16
|
||||||
|
let kind: Kind
|
||||||
|
|
||||||
|
init?(burstID: Data, seq: UInt16, kind: Kind) {
|
||||||
|
guard burstID.count == Self.burstIDSize else { return nil }
|
||||||
|
if case .frames(let frames) = kind {
|
||||||
|
guard !frames.isEmpty,
|
||||||
|
frames.count <= Self.maxFramesPerPacket,
|
||||||
|
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
|
||||||
|
else { return nil }
|
||||||
|
}
|
||||||
|
self.burstID = burstID
|
||||||
|
self.seq = seq
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var data = Data(capacity: Self.headerSize + payloadSize)
|
||||||
|
data.append(burstID)
|
||||||
|
data.append(UInt8((seq >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(seq & 0xFF))
|
||||||
|
switch kind {
|
||||||
|
case .start(let codec):
|
||||||
|
data.append(Flags.start)
|
||||||
|
data.append(codec.rawValue)
|
||||||
|
case .frames(let frames):
|
||||||
|
data.append(0)
|
||||||
|
for frame in frames {
|
||||||
|
let length = UInt16(frame.count)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
data.append(frame)
|
||||||
|
}
|
||||||
|
case .end(let totalDataPackets, let durationMs):
|
||||||
|
data.append(Flags.end)
|
||||||
|
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(totalDataPackets & 0xFF))
|
||||||
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
case .canceled:
|
||||||
|
data.append(Flags.canceled)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> VoiceBurstPacket? {
|
||||||
|
// Work on a re-based copy so subscripting is offset-safe.
|
||||||
|
let data = Data(data)
|
||||||
|
guard data.count >= headerSize else { return nil }
|
||||||
|
|
||||||
|
let burstID = data.prefix(burstIDSize)
|
||||||
|
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
|
||||||
|
let flags = data[burstIDSize + 2]
|
||||||
|
let payload = data.dropFirst(headerSize)
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
switch flags {
|
||||||
|
case Flags.start:
|
||||||
|
guard let codecByte = payload.first,
|
||||||
|
let codec = VoiceBurstCodec(rawValue: codecByte)
|
||||||
|
else { return nil }
|
||||||
|
kind = .start(codec: codec)
|
||||||
|
case Flags.end:
|
||||||
|
guard payload.count >= 6 else { return nil }
|
||||||
|
let bytes = Array(payload)
|
||||||
|
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
|
||||||
|
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||||
|
kind = .end(totalDataPackets: total, durationMs: duration)
|
||||||
|
case Flags.canceled:
|
||||||
|
kind = .canceled
|
||||||
|
case 0:
|
||||||
|
var frames: [Data] = []
|
||||||
|
var offset = payload.startIndex
|
||||||
|
while offset < payload.endIndex {
|
||||||
|
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
|
||||||
|
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
|
||||||
|
offset = payload.index(offset, offsetBy: 2)
|
||||||
|
guard length > 0,
|
||||||
|
payload.distance(from: offset, to: payload.endIndex) >= length,
|
||||||
|
frames.count < maxFramesPerPacket
|
||||||
|
else { return nil }
|
||||||
|
let end = payload.index(offset, offsetBy: length)
|
||||||
|
frames.append(Data(payload[offset..<end]))
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
guard !frames.isEmpty else { return nil }
|
||||||
|
kind = .frames(frames)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeBurstID() -> Data {
|
||||||
|
var bytes = Data(count: burstIDSize)
|
||||||
|
let result = bytes.withUnsafeMutableBytes {
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
|
||||||
|
}
|
||||||
|
guard result == errSecSuccess else {
|
||||||
|
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private var payloadSize: Int {
|
||||||
|
switch kind {
|
||||||
|
case .start: return 1
|
||||||
|
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
|
||||||
|
case .end: return 6
|
||||||
|
case .canceled: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy packetizer for outgoing bursts: batches encoded frames into
|
||||||
|
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
|
||||||
|
/// packet in a single BLE frame after Noise encryption and padding.
|
||||||
|
/// Not thread-safe — confine to one queue.
|
||||||
|
struct VoiceBurstPacketizer {
|
||||||
|
let burstID: Data
|
||||||
|
private let budget: Int
|
||||||
|
private var pendingFrames: [Data] = []
|
||||||
|
private var pendingSize = 0
|
||||||
|
/// seq 0 is reserved for START; data packets start at 1.
|
||||||
|
private(set) var nextSeq: UInt16 = 1
|
||||||
|
private(set) var dataPacketCount: UInt16 = 0
|
||||||
|
|
||||||
|
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
|
||||||
|
self.burstID = burstID
|
||||||
|
self.budget = budget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one encoded frame, returning any packets that became full.
|
||||||
|
/// Frames larger than the budget are dropped (the encoder's ~130-byte
|
||||||
|
/// frames never hit this; it guards against misconfiguration looping).
|
||||||
|
mutating func add(_ frame: Data) -> [Data] {
|
||||||
|
let frameCost = 2 + frame.count
|
||||||
|
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
|
||||||
|
|
||||||
|
var packets: [Data] = []
|
||||||
|
if !pendingFrames.isEmpty,
|
||||||
|
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|
||||||
|
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
|
||||||
|
packets.append(contentsOf: flush())
|
||||||
|
}
|
||||||
|
pendingFrames.append(frame)
|
||||||
|
pendingSize += frameCost
|
||||||
|
return packets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits any buffered frames as a final data packet.
|
||||||
|
mutating func flush() -> [Data] {
|
||||||
|
guard !pendingFrames.isEmpty,
|
||||||
|
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
|
||||||
|
else {
|
||||||
|
pendingFrames = []
|
||||||
|
pendingSize = 0
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
pendingFrames = []
|
||||||
|
pendingSize = 0
|
||||||
|
nextSeq &+= 1
|
||||||
|
dataPacketCount &+= 1
|
||||||
|
return [packet.encode()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//
|
||||||
|
// VouchAttestation.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A signed statement that the *sender of the enclosing Noise payload* has
|
||||||
|
/// verified the identity described here ("transitive verification").
|
||||||
|
///
|
||||||
|
/// The voucher's identity is deliberately implicit: attestations only travel
|
||||||
|
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
|
||||||
|
/// receiver verifies the Ed25519 signature against the session peer's
|
||||||
|
/// announce-bound signing key and stores the vouch keyed by that peer's
|
||||||
|
/// fingerprint. Nothing in the attestation names the voucher, so a captured
|
||||||
|
/// attestation cannot be replayed by a third party whose signing key doesn't
|
||||||
|
/// match.
|
||||||
|
///
|
||||||
|
/// Wire format — single attestation (TLV, 1-byte type + 1-byte length):
|
||||||
|
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
|
||||||
|
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
|
||||||
|
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
|
||||||
|
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
|
||||||
|
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
|
||||||
|
///
|
||||||
|
/// Unknown TLV types are skipped for forward compatibility.
|
||||||
|
///
|
||||||
|
/// Batch format (the `vouch` Noise payload body):
|
||||||
|
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
|
||||||
|
struct VouchAttestation: Equatable {
|
||||||
|
static let signingContext = "bitchat-vouch-v1"
|
||||||
|
/// Receiver-side expiry for attestations.
|
||||||
|
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
||||||
|
/// Tolerated clock skew for attestations timestamped in the future.
|
||||||
|
static let maxClockSkew: TimeInterval = 60 * 60
|
||||||
|
/// Upper bound of attestations carried/accepted in one batch payload.
|
||||||
|
static let maxBatchCount = 16
|
||||||
|
|
||||||
|
static let fingerprintSize = 32
|
||||||
|
static let signingKeySize = 32
|
||||||
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
let voucheeFingerprint: Data // 32 bytes
|
||||||
|
let voucheeSigningKey: Data // 32 bytes
|
||||||
|
let timestampMs: UInt64
|
||||||
|
let signature: Data // 64 bytes
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case voucheeFingerprint = 0x01
|
||||||
|
case voucheeSigningKey = 0x02
|
||||||
|
case timestamp = 0x03
|
||||||
|
case signature = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
|
||||||
|
|
||||||
|
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
|
||||||
|
|
||||||
|
/// The exact bytes the voucher signs.
|
||||||
|
static func signableBytes(
|
||||||
|
voucheeFingerprint: Data,
|
||||||
|
voucheeSigningKey: Data,
|
||||||
|
timestampMs: UInt64
|
||||||
|
) -> Data {
|
||||||
|
var message = Data(signingContext.utf8)
|
||||||
|
message.append(voucheeFingerprint)
|
||||||
|
message.append(voucheeSigningKey)
|
||||||
|
var timestampBE = timestampMs.bigEndian
|
||||||
|
withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) }
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
var signableBytes: Data {
|
||||||
|
Self.signableBytes(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucheeSigningKey: voucheeSigningKey,
|
||||||
|
timestampMs: timestampMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
|
||||||
|
/// signing primitive (e.g. `Transport.noiseSignData`).
|
||||||
|
static func build(
|
||||||
|
voucheeFingerprint: Data,
|
||||||
|
voucheeSigningKey: Data,
|
||||||
|
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
sign: (Data) -> Data?
|
||||||
|
) -> VouchAttestation? {
|
||||||
|
guard voucheeFingerprint.count == fingerprintSize,
|
||||||
|
voucheeSigningKey.count == signingKeySize else { return nil }
|
||||||
|
let message = signableBytes(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucheeSigningKey: voucheeSigningKey,
|
||||||
|
timestampMs: timestampMs
|
||||||
|
)
|
||||||
|
guard let signature = sign(message), signature.count == signatureSize else { return nil }
|
||||||
|
return VouchAttestation(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucheeSigningKey: voucheeSigningKey,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the Ed25519 signature against the voucher's announce-bound
|
||||||
|
/// signing key.
|
||||||
|
func verifySignature(voucherSigningKey: Data) -> Bool {
|
||||||
|
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return publicKey.isValidSignature(signature, for: signableBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the attestation is outside its validity window (older than
|
||||||
|
/// `maxAge`, or timestamped implausibly far in the future).
|
||||||
|
func isExpired(now: Date = Date()) -> Bool {
|
||||||
|
let age = now.timeIntervalSince(timestamp)
|
||||||
|
return age > Self.maxAge || age < -Self.maxClockSkew
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encoding
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard voucheeFingerprint.count == Self.fingerprintSize,
|
||||||
|
voucheeSigningKey.count == Self.signingKeySize,
|
||||||
|
signature.count == Self.signatureSize else { return nil }
|
||||||
|
var data = Data()
|
||||||
|
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||||
|
data.append(type.rawValue)
|
||||||
|
data.append(UInt8(value.count))
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
appendTLV(.voucheeFingerprint, voucheeFingerprint)
|
||||||
|
appendTLV(.voucheeSigningKey, voucheeSigningKey)
|
||||||
|
var timestampBE = timestampMs.bigEndian
|
||||||
|
appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) })
|
||||||
|
appendTLV(.signature, signature)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> VouchAttestation? {
|
||||||
|
var fingerprint: Data?
|
||||||
|
var signingKey: Data?
|
||||||
|
var timestampMs: UInt64?
|
||||||
|
var signature: Data?
|
||||||
|
|
||||||
|
var offset = data.startIndex
|
||||||
|
while offset < data.endIndex {
|
||||||
|
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
|
||||||
|
offset + 1 < data.endIndex else { return nil }
|
||||||
|
let type = data[offset]
|
||||||
|
let length = Int(data[offset + 1])
|
||||||
|
let valueStart = offset + 2
|
||||||
|
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let value = Data(data[valueStart..<valueEnd])
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .voucheeFingerprint:
|
||||||
|
guard value.count == fingerprintSize else { return nil }
|
||||||
|
fingerprint = value
|
||||||
|
case .voucheeSigningKey:
|
||||||
|
guard value.count == signingKeySize else { return nil }
|
||||||
|
signingKey = value
|
||||||
|
case .timestamp:
|
||||||
|
guard value.count == 8 else { return nil }
|
||||||
|
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .signature:
|
||||||
|
guard value.count == signatureSize else { return nil }
|
||||||
|
signature = value
|
||||||
|
case nil:
|
||||||
|
break // Unknown TLV: skip for forward compatibility.
|
||||||
|
}
|
||||||
|
offset = valueEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
|
||||||
|
return VouchAttestation(
|
||||||
|
voucheeFingerprint: fingerprint,
|
||||||
|
voucheeSigningKey: signingKey,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch encoding
|
||||||
|
|
||||||
|
/// Encodes up to `maxBatchCount` attestations into one payload body.
|
||||||
|
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
|
||||||
|
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
|
||||||
|
var data = Data()
|
||||||
|
data.append(UInt8(attestations.count))
|
||||||
|
for attestation in attestations {
|
||||||
|
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
|
||||||
|
var lengthBE = UInt16(encoded.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
|
||||||
|
data.append(encoded)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a batch payload, dropping malformed entries and ignoring
|
||||||
|
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
|
||||||
|
static func decodeList(from data: Data) -> [VouchAttestation] {
|
||||||
|
guard data.count > 1 else { return [] }
|
||||||
|
let declaredCount = Int(data[data.startIndex])
|
||||||
|
let limit = min(declaredCount, maxBatchCount)
|
||||||
|
var attestations: [VouchAttestation] = []
|
||||||
|
var offset = data.startIndex + 1
|
||||||
|
while attestations.count < limit, offset < data.endIndex {
|
||||||
|
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
|
||||||
|
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
|
||||||
|
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
|
||||||
|
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
|
||||||
|
attestations.append(attestation)
|
||||||
|
}
|
||||||
|
offset = entryEnd
|
||||||
|
}
|
||||||
|
return attestations
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,7 @@ import Foundation
|
|||||||
/// Manages autocomplete functionality for chat
|
/// Manages autocomplete functionality for chat
|
||||||
final class AutocompleteService {
|
final class AutocompleteService {
|
||||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
|
||||||
|
|
||||||
private let commands = [
|
|
||||||
"/msg", "/who", "/clear",
|
|
||||||
"/hug", "/slap", "/fav", "/unfav",
|
|
||||||
"/block", "/unblock"
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Get autocomplete suggestions for current text
|
/// Get autocomplete suggestions for current text
|
||||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||||
let textToPosition = String(text.prefix(cursorPosition))
|
let textToPosition = String(text.prefix(cursorPosition))
|
||||||
@@ -73,26 +66,6 @@ final class AutocompleteService {
|
|||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
|
||||||
guard let regex = commandRegex else { return nil }
|
|
||||||
|
|
||||||
let nsText = text as NSString
|
|
||||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
|
||||||
|
|
||||||
guard let match = matches.last else { return nil }
|
|
||||||
|
|
||||||
let fullRange = match.range(at: 0)
|
|
||||||
let captureRange = match.range(at: 1)
|
|
||||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
|
||||||
|
|
||||||
let suggestions = commands
|
|
||||||
.filter { $0.hasPrefix("/\(prefix)") }
|
|
||||||
.sorted()
|
|
||||||
.prefix(5)
|
|
||||||
|
|
||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func needsArgument(command: String) -> Bool {
|
private func needsArgument(command: String) -> Bool {
|
||||||
switch command {
|
switch command {
|
||||||
case "/who", "/clear":
|
case "/who", "/clear":
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let localNickname: () -> String
|
let localNickname: () -> String
|
||||||
/// Snapshot of known peers keyed by ID (registry read).
|
/// Snapshot of known peers keyed by ID (registry read).
|
||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
|
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||||
|
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
/// Tracks the broadcast file packet for gossip sync.
|
||||||
@@ -44,49 +46,54 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
/// Returns `false` when the packet fails sender authentication and must
|
||||||
/// pass the ceiling that was enforced against the accepted offer.
|
/// not be relayed onward. Every other outcome returns `true`: files
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
/// directed to another peer are forwarded untouched, and local-only drops
|
||||||
|
/// (malformed payload, quota, save failure) don't affect multi-hop
|
||||||
|
/// delivery to nodes that may handle them fine.
|
||||||
|
@discardableResult
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peersSnapshot,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
|
||||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let peersSnapshot = env.peersSnapshot()
|
||||||
|
guard let senderNickname = resolveSenderNickname(
|
||||||
|
packet: packet,
|
||||||
|
from: peerID,
|
||||||
|
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||||
|
peers: peersSnapshot,
|
||||||
|
env: env
|
||||||
|
) else {
|
||||||
|
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if deliveryPlan.shouldTrackForSync {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
case .failure(.malformedPayload):
|
case .failure(.malformedPayload):
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return
|
return true
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
return
|
return true
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return
|
return true
|
||||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -99,7 +106,7 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if deliveryPlan.isPrivateMessage {
|
||||||
@@ -115,11 +122,66 @@ final class BLEFileTransferHandler {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID,
|
||||||
|
// Received messages need an explicit status: BitchatMessage
|
||||||
|
// defaults private messages to .sending, which the media views
|
||||||
|
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||||
|
deliveryStatus: deliveryPlan.isPrivateMessage
|
||||||
|
? .delivered(to: env.localNickname(), at: ts)
|
||||||
|
: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
env.deliverMessage(message)
|
env.deliverMessage(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the authenticated display name for a file transfer's sender.
|
||||||
|
///
|
||||||
|
/// Directed (private) transfers are addressed to us specifically and keep
|
||||||
|
/// the lenient connected-peer path. Broadcast transfers carry an
|
||||||
|
/// attacker-controllable `senderID` exactly like public messages and public
|
||||||
|
/// voice frames — registry membership alone is NOT proof of identity, so a
|
||||||
|
/// valid packet signature from the claimed sender is required before we
|
||||||
|
/// trust it. Without this, a peer that observed a public voice burst could
|
||||||
|
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
||||||
|
/// overwrite the signature-verified live bubble with attacker audio.
|
||||||
|
private func resolveSenderNickname(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
isBroadcast: Bool,
|
||||||
|
peers: [PeerID: BLEPeerInfo],
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> String? {
|
||||||
|
guard isBroadcast else {
|
||||||
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: true
|
||||||
|
) ?? env.signedSenderDisplayName(packet, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
||||||
|
// trivially authentic and cannot be verified against the peer registry
|
||||||
|
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
||||||
|
// does. Verify against the signing key already in the
|
||||||
|
// (synchronously-updated) registry first, then fall back to the
|
||||||
|
// persisted-identity signature lookup for peers not yet cached there.
|
||||||
|
let isSelf = peerID == env.localPeerID()
|
||||||
|
let registrySigningKey = peers[peerID]?.signingPublicKey
|
||||||
|
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||||
|
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||||
|
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
||||||
|
|
||||||
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) ?? signedDisplayName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,17 +42,12 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
enum BLEIncomingFileValidator {
|
||||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
/// pass the ceiling enforced against the accepted offer.
|
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||||
static func validate(
|
|
||||||
payload: Data,
|
|
||||||
limit: Int = FileTransferLimits.maxPayloadBytes
|
|
||||||
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
|
||||||
return .failure(.malformedPayload)
|
return .failure(.malformedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct Metadata {
|
private struct Metadata {
|
||||||
let type: UInt8
|
|
||||||
let total: Int
|
let total: Int
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
let isBroadcast: Bool
|
||||||
|
var lastFragmentAt: Date
|
||||||
|
var lastResyncRequestAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||||
@@ -105,7 +107,15 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only actual progress resets the stall clock: fragment packets
|
||||||
|
// bypass the packet deduplicator, so relayed duplicates of an
|
||||||
|
// already-held index must not keep suppressing the targeted
|
||||||
|
// REQUEST_SYNC for a stalled stream.
|
||||||
|
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
|
||||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||||
|
if isNewIndex {
|
||||||
|
metadataByKey[header.key]?.lastFragmentAt = now
|
||||||
|
}
|
||||||
|
|
||||||
guard let fragments = fragmentsByKey[header.key],
|
guard let fragments = fragmentsByKey[header.key],
|
||||||
fragments.count == header.total else {
|
fragments.count == header.total else {
|
||||||
@@ -138,10 +148,58 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
fragmentsByKey[header.key] = [:]
|
||||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
metadataByKey[header.key] = Metadata(
|
||||||
|
total: header.total,
|
||||||
|
timestamp: now,
|
||||||
|
isBroadcast: header.isBroadcastFragment,
|
||||||
|
lastFragmentAt: now
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
|
||||||
|
/// reassemblies that have not seen a new fragment for `stalledAfter`
|
||||||
|
/// seconds — candidates for a targeted REQUEST_SYNC. Each returned
|
||||||
|
/// stream is marked so it is not re-requested within `retryAfter`.
|
||||||
|
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
|
||||||
|
/// returned per pass — the wire filter cannot carry more — selected
|
||||||
|
/// oldest-stall first; overflow streams stay unmarked and eligible for
|
||||||
|
/// the next pass. Directed reassemblies are excluded: peers only archive
|
||||||
|
/// broadcast fragments for gossip sync, so a targeted request cannot
|
||||||
|
/// recover them.
|
||||||
|
mutating func stalledBroadcastFragmentIDs(
|
||||||
|
stalledAfter: TimeInterval,
|
||||||
|
retryAfter: TimeInterval,
|
||||||
|
now: Date = Date()
|
||||||
|
) -> [Data] {
|
||||||
|
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
|
||||||
|
for (key, metadata) in metadataByKey {
|
||||||
|
guard metadata.isBroadcast,
|
||||||
|
let fragments = fragmentsByKey[key],
|
||||||
|
fragments.count < metadata.total,
|
||||||
|
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
|
||||||
|
if let lastRequest = metadata.lastResyncRequestAt,
|
||||||
|
now.timeIntervalSince(lastRequest) < retryAfter { continue }
|
||||||
|
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark only the streams that will actually go on the wire, so the
|
||||||
|
// overflow is not silently suppressed for `retryAfter`.
|
||||||
|
let selected = candidates
|
||||||
|
.sorted {
|
||||||
|
if $0.lastFragmentAt != $1.lastFragmentAt {
|
||||||
|
return $0.lastFragmentAt < $1.lastFragmentAt
|
||||||
|
}
|
||||||
|
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
|
||||||
|
}
|
||||||
|
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
|
||||||
|
|
||||||
|
return selected.map { candidate in
|
||||||
|
metadataByKey[candidate.key]?.lastResyncRequestAt = now
|
||||||
|
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||||
if originalType == MessageType.fileTransfer.rawValue {
|
if originalType == MessageType.fileTransfer.rawValue {
|
||||||
// Allow headroom for TLV metadata and binary framing overhead.
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
|||||||
@@ -33,13 +33,21 @@ final class BLEFragmentHandler {
|
|||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Don't process our own fragments
|
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||||
|
|
||||||
|
// Sync replay legitimately hands us our own fragments back (the RSR
|
||||||
|
// ttl=0 restore path): after a relaunch the fragment store starts
|
||||||
|
// empty, so our sync filter doesn't cover them and peers re-offer
|
||||||
|
// them. Record them as seen — the next round's filter then covers
|
||||||
|
// them and the redelivery stops — but skip assembly: we authored
|
||||||
|
// the original, there is nothing to reassemble.
|
||||||
if peerID == env.localPeerID() {
|
if peerID == env.localPeerID() {
|
||||||
|
if header.isBroadcastFragment {
|
||||||
|
env.trackPacketSeen(packet)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
|
||||||
|
|
||||||
if header.isBroadcastFragment {
|
if header.isBroadcastFragment {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,10 +78,21 @@ struct BLEIngressLinkRegistry {
|
|||||||
return .failure(.selfLoopback(packetType: packet.type))
|
return .failure(.selfLoopback(packetType: packet.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let boundPeerID,
|
if let boundPeerID, boundPeerID != claimedSenderID {
|
||||||
boundPeerID != claimedSenderID,
|
if requiresDirectSenderBinding(packet) {
|
||||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
}
|
||||||
|
// A direct announce claiming a new sender on a bound link is either
|
||||||
|
// a spoof or a legitimate peer-ID rotation on a connection that
|
||||||
|
// outlived the old ID. Attribute it to the claimed sender and let
|
||||||
|
// it through: announces are self-authenticating, and only a
|
||||||
|
// signature-verified announce may rebind the link (BLEService).
|
||||||
|
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||||
|
return .success(BLEIngressPacketContext(
|
||||||
|
receivedFromPeerID: claimedSenderID,
|
||||||
|
validationPeerID: claimedSenderID
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||||
@@ -98,12 +109,15 @@ struct BLEIngressLinkRegistry {
|
|||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
|
||||||
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
||||||
// must be the link peer — it elicits a full store replay, and the
|
// must be the link peer — it elicits a full store replay, and the
|
||||||
// response is addressed to whoever the sender claims to be.
|
// response is addressed to whoever the sender claims to be.
|
||||||
if packet.type == MessageType.requestSync.rawValue { return true }
|
packet.type == MessageType.requestSync.rawValue
|
||||||
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
}
|
||||||
|
|
||||||
|
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||||
|
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||||
|
|||||||
@@ -203,9 +203,19 @@ final class BLELinkStateStore {
|
|||||||
|
|
||||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||||
assertOwned()
|
assertOwned()
|
||||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
var previousPeerID: PeerID?
|
||||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
let updated = updatePeripheral(peripheralUUID) {
|
||||||
|
previousPeerID = $0.peerID
|
||||||
|
$0.peerID = peerID
|
||||||
}
|
}
|
||||||
|
guard updated != nil else { return }
|
||||||
|
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
||||||
|
// so the old peer no longer claims this link.
|
||||||
|
if let previousPeerID, previousPeerID != peerID,
|
||||||
|
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
||||||
|
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
||||||
|
}
|
||||||
|
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||||
|
|||||||
@@ -25,9 +25,4 @@ final class BLELogRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeAll() {
|
|
||||||
queue.sync {
|
|
||||||
lastLogTimeByKey.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
||||||
|
// push every ~490-byte signed voice packet over the MTU into the
|
||||||
|
// fragment path.
|
||||||
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority {
|
||||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case .fragment:
|
case .fragment:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
var capabilities: PeerCapabilities = []
|
var capabilities: PeerCapabilities = []
|
||||||
|
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||||
|
var bridgeGeohash: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
@@ -112,6 +114,20 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID.toShort()]?.capabilities ?? []
|
peers[peerID.toShort()]?.capabilities ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peers whose last verified announce advertised the given capability.
|
||||||
|
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||||
|
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rendezvous cell advertised by any bridge-capable peer, if one is
|
||||||
|
/// known — lets location-less devices join the island's rendezvous.
|
||||||
|
func advertisedBridgeGeohash() -> String? {
|
||||||
|
peers.values
|
||||||
|
.filter { $0.capabilities.contains(.bridge) }
|
||||||
|
.compactMap(\.bridgeGeohash)
|
||||||
|
.first
|
||||||
|
}
|
||||||
|
|
||||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
@@ -136,14 +152,6 @@ struct BLEPeerRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
|
||||||
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
|
||||||
let hasCollision = peers.values.contains {
|
|
||||||
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
|
||||||
} || selfNickname == info.nickname
|
|
||||||
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func markDisconnected(_ peerID: PeerID) {
|
mutating func markDisconnected(_ peerID: PeerID) {
|
||||||
guard var info = peers[peerID] else { return }
|
guard var info = peers[peerID] else { return }
|
||||||
info.isConnected = false
|
info.isConnected = false
|
||||||
@@ -163,7 +171,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date,
|
now: Date,
|
||||||
capabilities: PeerCapabilities = []
|
capabilities: PeerCapabilities = [],
|
||||||
|
bridgeGeohash: String? = nil
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
@@ -180,7 +189,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now,
|
lastSeen: now,
|
||||||
capabilities: capabilities
|
capabilities: capabilities,
|
||||||
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|||||||
@@ -51,13 +51,26 @@ struct BLEReceivePipeline {
|
|||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
// remote handover toward a relayed announce rides this same
|
// remote handover toward a relayed announce rides this same
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
|
// Ping/pong diagnostics ride it too: probes need the same
|
||||||
|
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||||
|
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||||
|
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||||
|
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||||
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||||
|
|| packet.type == MessageType.ping.rawValue
|
||||||
|
|| packet.type == MessageType.pong.rawValue
|
||||||
|
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||||
|
// Board posts relay like broadcast messages; urgent ones get the
|
||||||
|
// announce-class TTL headroom so alerts travel the extra hop.
|
||||||
|
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||||
|
&& BoardWire.urgentFlag(in: packet.payload),
|
||||||
|
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
|
||||||
degree: degree,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
|
||||||
|
/// links) so the service can arm pending background connections against them
|
||||||
|
/// when the app leaves the foreground. Generic over the peripheral type so
|
||||||
|
/// the eviction/expiry logic is testable without CoreBluetooth.
|
||||||
|
final class BLERecentPeripheralCache<Peripheral> {
|
||||||
|
private struct Entry {
|
||||||
|
let peripheral: Peripheral
|
||||||
|
var lastSeen: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entries: [String: Entry] = [:]
|
||||||
|
private let capacity: Int
|
||||||
|
private let maxAge: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
|
||||||
|
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
|
||||||
|
) {
|
||||||
|
self.capacity = capacity
|
||||||
|
self.maxAge = maxAge
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int { entries.count }
|
||||||
|
|
||||||
|
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
|
||||||
|
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
|
||||||
|
guard entries.count > capacity else { return }
|
||||||
|
// Inserts overshoot capacity by at most one; evict the stalest entry
|
||||||
|
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
|
||||||
|
entries.removeValue(forKey: stalest.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most-recently-seen peripherals eligible for a pending background
|
||||||
|
/// connect, freshest first, capped at `limit`. Expired entries are
|
||||||
|
/// pruned as a side effect.
|
||||||
|
func reconnectTargets(
|
||||||
|
now: Date,
|
||||||
|
limit: Int,
|
||||||
|
excluding: (String) -> Bool
|
||||||
|
) -> [(peripheralID: String, peripheral: Peripheral)] {
|
||||||
|
let cutoff = now.addingTimeInterval(-maxAge)
|
||||||
|
entries = entries.filter { $0.value.lastSeen >= cutoff }
|
||||||
|
guard limit > 0 else { return [] }
|
||||||
|
return entries
|
||||||
|
.filter { !excluding($0.key) }
|
||||||
|
.sorted { $0.value.lastSeen > $1.value.lastSeen }
|
||||||
|
.prefix(limit)
|
||||||
|
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tracks whether source-routed sends to a recipient appear to be working.
|
||||||
|
///
|
||||||
|
/// A routed unicast rides exactly one path, so a broken hop silently loses the
|
||||||
|
/// packet where a flood would have healed around it. Rather than building a
|
||||||
|
/// retransmission machine (MessageRouter already retries at a higher layer),
|
||||||
|
/// this cache degrades: a routed send that sees no inbound traffic from the
|
||||||
|
/// recipient within the confirmation window marks the route as failed, and
|
||||||
|
/// subsequent sends fall back to flooding until the suppression TTL lapses.
|
||||||
|
struct BLESourceRouteFailureCache {
|
||||||
|
struct Config {
|
||||||
|
/// How long a routed send may go unconfirmed before it counts as a
|
||||||
|
/// route failure.
|
||||||
|
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
|
||||||
|
/// How long to flood instead of routing after a failure.
|
||||||
|
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct State {
|
||||||
|
var pendingSince: Date?
|
||||||
|
var suppressedUntil: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let config: Config
|
||||||
|
private var states: [PeerID: State] = [:]
|
||||||
|
|
||||||
|
init(config: Config = Config()) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the next directed send to `recipient` may carry a source
|
||||||
|
/// route. Flips the recipient into suppression when the last routed send
|
||||||
|
/// went unconfirmed past the confirmation window.
|
||||||
|
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
|
||||||
|
guard var state = states[recipient] else { return true }
|
||||||
|
|
||||||
|
if let until = state.suppressedUntil {
|
||||||
|
guard now >= until else { return false }
|
||||||
|
state.suppressedUntil = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pending = state.pendingSince,
|
||||||
|
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
|
||||||
|
// The routed send was never confirmed: treat the route as broken
|
||||||
|
// and flood until the suppression window lapses.
|
||||||
|
state.pendingSince = nil
|
||||||
|
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
|
||||||
|
states[recipient] = state
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
states[recipient] = state
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a source-routed packet was sent to `recipient`. Keeps the
|
||||||
|
/// earliest unconfirmed send so back-to-back packets share one deadline.
|
||||||
|
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
|
||||||
|
var state = states[recipient] ?? State()
|
||||||
|
if state.pendingSince == nil {
|
||||||
|
state.pendingSince = now
|
||||||
|
}
|
||||||
|
states[recipient] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any inbound packet authored by `peer` confirms the pending routed send
|
||||||
|
/// (delivery acks and replies arrive this way). Deliberately does not
|
||||||
|
/// lift an active suppression: that traffic may have arrived via flood.
|
||||||
|
mutating func noteInboundActivity(from peer: PeerID) {
|
||||||
|
guard var state = states[peer] else { return }
|
||||||
|
state.pendingSince = nil
|
||||||
|
if state.suppressedUntil == nil {
|
||||||
|
states.removeValue(forKey: peer)
|
||||||
|
} else {
|
||||||
|
states[peer] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops entries that can no longer influence a routing decision. An
|
||||||
|
/// expired-but-unconverted pending entry is kept for as long as the
|
||||||
|
/// suppression it would trigger could still be active.
|
||||||
|
mutating func prune(now: Date = Date()) {
|
||||||
|
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
|
||||||
|
states = states.filter { _, state in
|
||||||
|
if let until = state.suppressedUntil, now < until { return true }
|
||||||
|
if let pending = state.pendingSince,
|
||||||
|
now.timeIntervalSince(pending) <= pendingRetention {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import BitFoundation
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// Routes are only originated when every gate passes:
|
||||||
|
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||||
|
/// else's packet; route-following for in-flight routed packets lives in
|
||||||
|
/// `BLERouteForwardingPolicy`),
|
||||||
|
/// - the packet is directed at a single peer (not broadcast),
|
||||||
|
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
|
||||||
|
/// packets like REQUEST_SYNC never route),
|
||||||
|
/// - the recipient is not directly connected (a direct write already
|
||||||
|
/// delivers in one hop),
|
||||||
|
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||||
|
/// routed send, and
|
||||||
|
/// - the topology yields a complete path.
|
||||||
|
static func route(
|
||||||
|
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 }
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//
|
||||||
|
// BoardAlertsModel.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Turns newly arriving board posts into local, scope-matched chat alerts.
|
||||||
|
/// Everything here is derived from posts the mesh already synced — no extra
|
||||||
|
/// wire traffic, nothing another peer can't already see.
|
||||||
|
///
|
||||||
|
/// - Urgent, recent pins get one system line in the matching chat (geo pin →
|
||||||
|
/// that geohash's timeline, mesh pin → mesh chat), collapsed when several
|
||||||
|
/// arrive together.
|
||||||
|
/// - Every other new pin just marks the header's pin icon until the notices
|
||||||
|
/// sheet is opened.
|
||||||
|
@MainActor
|
||||||
|
final class BoardAlertsModel: ObservableObject {
|
||||||
|
struct Dependencies {
|
||||||
|
/// Own posts never alert; the author already knows.
|
||||||
|
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
|
||||||
|
/// Appends a local system line to a scope's chat timeline
|
||||||
|
/// (geohash, or "" for mesh chat).
|
||||||
|
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
|
||||||
|
var now: () -> Date = Date.init
|
||||||
|
/// Schedules the collapsed flush of pending urgent alerts; tests
|
||||||
|
/// inject a synchronous hook.
|
||||||
|
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts older than this at arrival are backfilled history carried in by
|
||||||
|
/// a peer, not something happening now; they badge but never line the chat.
|
||||||
|
static let inlineRecencyWindow: TimeInterval = 30 * 60
|
||||||
|
/// Urgent arrivals within this window collapse into one line.
|
||||||
|
static let collapseDelaySeconds: TimeInterval = 4
|
||||||
|
private static let alertContentMaxChars = 120
|
||||||
|
|
||||||
|
/// Unseen new pins by postID (hex) → geohash scope, cleared when the
|
||||||
|
/// notices sheet opens.
|
||||||
|
@Published private(set) var unseenPostScopes: [String: String] = [:]
|
||||||
|
|
||||||
|
/// PostIDs already handled this session, so store eviction/re-sync churn
|
||||||
|
/// can't re-alert. Bounded by session wire volume (32-byte strings).
|
||||||
|
private var handledPostIDs = Set<String>()
|
||||||
|
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
|
||||||
|
private var flushScheduled = false
|
||||||
|
private let dependencies: Dependencies
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
private var wipeCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static func urgentSingle(author: String, content: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
|
||||||
|
locale: .current,
|
||||||
|
author, content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func urgentCollapsed(_ count: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
|
||||||
|
locale: .current,
|
||||||
|
count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
arrivals: AnyPublisher<BoardPostPacket, Never>,
|
||||||
|
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
|
||||||
|
dependencies: Dependencies
|
||||||
|
) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
cancellable = arrivals
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] post in
|
||||||
|
self?.handleArrival(post)
|
||||||
|
}
|
||||||
|
wipeCancellable = wipes
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] in
|
||||||
|
self?.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unseenCount(forGeohash geohash: String) -> Int {
|
||||||
|
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks pins in the given scopes as seen — only the scopes the notices
|
||||||
|
/// sheet actually shows, so unseen pins for other geohash channels keep
|
||||||
|
/// their badge until visited.
|
||||||
|
func markSeen(forScopes scopes: Set<String>) {
|
||||||
|
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
|
||||||
|
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic wipe: drop everything derived from pre-wipe posts, including
|
||||||
|
/// urgent lines still waiting on the collapse flush.
|
||||||
|
func reset() {
|
||||||
|
pendingUrgent.removeAll()
|
||||||
|
handledPostIDs.removeAll()
|
||||||
|
guard !unseenPostScopes.isEmpty else { return }
|
||||||
|
unseenPostScopes.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleArrival(_ post: BoardPostPacket) {
|
||||||
|
let postID = post.postID.hexEncodedString()
|
||||||
|
guard !handledPostIDs.contains(postID) else { return }
|
||||||
|
handledPostIDs.insert(postID)
|
||||||
|
guard !dependencies.isOwnPost(post) else { return }
|
||||||
|
|
||||||
|
unseenPostScopes[postID] = post.geohash
|
||||||
|
|
||||||
|
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
||||||
|
guard post.isUrgent,
|
||||||
|
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingUrgent[post.geohash, default: []].append(post)
|
||||||
|
if !flushScheduled {
|
||||||
|
flushScheduled = true
|
||||||
|
dependencies.scheduleFlush { [weak self] in
|
||||||
|
self?.flushPendingUrgent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flushPendingUrgent() {
|
||||||
|
flushScheduled = false
|
||||||
|
let pending = pendingUrgent
|
||||||
|
pendingUrgent.removeAll()
|
||||||
|
for (geohash, posts) in pending {
|
||||||
|
guard let first = posts.first else { continue }
|
||||||
|
let line: String
|
||||||
|
if posts.count == 1 {
|
||||||
|
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
||||||
|
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
|
||||||
|
} else {
|
||||||
|
line = Strings.urgentCollapsed(posts.count)
|
||||||
|
}
|
||||||
|
dependencies.emitSystemLine(line, geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func truncated(_ content: String) -> String {
|
||||||
|
guard content.count > alertContentMaxChars else { return content }
|
||||||
|
return content.prefix(alertContentMaxChars) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//
|
||||||
|
// BoardManager.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// UI-facing coordinator for the bulletin board: builds and signs posts and
|
||||||
|
/// tombstones with the device's Noise signing key, hands them to the mesh
|
||||||
|
/// transport, and mirrors the store's live posts for SwiftUI.
|
||||||
|
@MainActor
|
||||||
|
final class BoardManager: ObservableObject {
|
||||||
|
/// Live posts across all boards, newest state from the store.
|
||||||
|
@Published private(set) var posts: [BoardPostPacket] = []
|
||||||
|
|
||||||
|
private let transport: Transport
|
||||||
|
/// 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, _ urgent: Bool) -> String?
|
||||||
|
/// Requests NIP-09 deletion of a previously bridged note.
|
||||||
|
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
|
||||||
|
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
|
||||||
|
/// after a relaunch a delete still tombstones the board copy, but the
|
||||||
|
/// Nostr copy is left to expire with relay retention.
|
||||||
|
private var bridgedEventIDs: [Data: String] = [:]
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
|
init(
|
||||||
|
transport: Transport,
|
||||||
|
store: BoardStore = .shared,
|
||||||
|
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
|
||||||
|
deleteFromNostr: ((String, String) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.transport = transport
|
||||||
|
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||||
|
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
||||||
|
cancellable = store.$postsSnapshot
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] snapshot in
|
||||||
|
self?.posts = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts for one board context, urgent first, then newest first.
|
||||||
|
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||||
|
posts
|
||||||
|
.filter { $0.geohash == geohash }
|
||||||
|
.sorted {
|
||||||
|
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
||||||
|
return $0.createdAt > $1.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOwnPost(_ post: BoardPostPacket) -> Bool {
|
||||||
|
let key = transport.noiseSigningPublicKeyData()
|
||||||
|
return !key.isEmpty && key == post.authorSigningKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates, signs, and broadcasts a board post. Returns false when the
|
||||||
|
/// content is empty/oversized or signing fails.
|
||||||
|
@discardableResult
|
||||||
|
func createPost(
|
||||||
|
content: String,
|
||||||
|
geohash: String,
|
||||||
|
urgent: Bool,
|
||||||
|
expiryDays: Int,
|
||||||
|
nickname: String
|
||||||
|
) -> Bool {
|
||||||
|
guard let trimmed = content.trimmedOrNilIfEmpty,
|
||||||
|
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let signingKey = transport.noiseSigningPublicKeyData()
|
||||||
|
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
|
||||||
|
|
||||||
|
var cleanNickname = nickname
|
||||||
|
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
|
||||||
|
cleanNickname.removeLast()
|
||||||
|
}
|
||||||
|
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let lifetimeMs = min(
|
||||||
|
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
|
||||||
|
BoardWireConstants.maxLifetimeMs
|
||||||
|
)
|
||||||
|
let expiresAt = createdAt + lifetimeMs
|
||||||
|
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
|
||||||
|
var postID = Data(count: BoardWireConstants.postIDLength)
|
||||||
|
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
|
||||||
|
guard let base = buffer.baseAddress else { return -1 }
|
||||||
|
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
|
||||||
|
}
|
||||||
|
guard status == errSecSuccess else { return false }
|
||||||
|
|
||||||
|
let signingBytes = BoardPostPacket.signingBytes(
|
||||||
|
postID: postID,
|
||||||
|
geohash: geohash,
|
||||||
|
content: trimmed,
|
||||||
|
authorSigningKey: signingKey,
|
||||||
|
authorNickname: cleanNickname,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
flags: flags
|
||||||
|
)
|
||||||
|
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||||
|
SecureLogger.error("Board: failed to sign post", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let post = BoardPostPacket(
|
||||||
|
postID: postID,
|
||||||
|
geohash: geohash,
|
||||||
|
content: trimmed,
|
||||||
|
authorSigningKey: signingKey,
|
||||||
|
authorNickname: cleanNickname,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
flags: flags,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
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, urgent) {
|
||||||
|
bridgedEventIDs[postID] = eventID
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs and broadcasts a tombstone for one of our own posts.
|
||||||
|
@discardableResult
|
||||||
|
func deletePost(_ post: BoardPostPacket) -> Bool {
|
||||||
|
guard isOwnPost(post) else { return false }
|
||||||
|
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
|
||||||
|
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||||
|
SecureLogger.error("Board: failed to sign tombstone", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let tombstone = BoardTombstonePacket(
|
||||||
|
postID: post.postID,
|
||||||
|
authorSigningKey: post.authorSigningKey,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
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, urgent: Bool) -> 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
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||||
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
|
content: content,
|
||||||
|
geohash: geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: nickname,
|
||||||
|
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
|
||||||
|
urgent: urgent
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//
|
||||||
|
// BoardStore.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 Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Outcome of feeding a board packet into the store, so the transport can
|
||||||
|
/// decide whether the packet is still worth relaying.
|
||||||
|
enum BoardIngestResult {
|
||||||
|
/// New post or tombstone accepted (or a quota rejected it locally while
|
||||||
|
/// it remains valid for other devices).
|
||||||
|
case accepted
|
||||||
|
/// Already known; nothing changed.
|
||||||
|
case duplicate
|
||||||
|
/// Invalid, expired, or deleted; do not relay.
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent storage for bulletin-board posts and their tombstones.
|
||||||
|
///
|
||||||
|
/// Posts are signed public notices designed to outlive chat: they stay on
|
||||||
|
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
|
||||||
|
/// sync after a restart. Tombstones are retained until the deleted post's
|
||||||
|
/// original expiry so the delete keeps outrunning stale copies of the post.
|
||||||
|
///
|
||||||
|
/// The on-disk format is the raw signed packets themselves (like
|
||||||
|
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
|
||||||
|
/// them on launch. Wiped on panic.
|
||||||
|
final class BoardStore {
|
||||||
|
enum Limits {
|
||||||
|
static let maxPosts = 200
|
||||||
|
static let maxPostsPerAuthor = 5
|
||||||
|
/// Retention for a tombstone whose post we never saw: we cannot know
|
||||||
|
/// the original expiry, so cap at the max post lifetime.
|
||||||
|
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
|
||||||
|
/// Orphan tombstones name posts nobody here has seen, so their volume
|
||||||
|
/// is entirely sender-controlled; cap them like posts.
|
||||||
|
static let maxOrphanTombstones = 100
|
||||||
|
static let maxOrphanTombstonesPerAuthor = 5
|
||||||
|
/// Allowance for clock skew between peers when judging received
|
||||||
|
/// timestamps against local time.
|
||||||
|
static let clockSkewMs: UInt64 = 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoredPost {
|
||||||
|
let post: BoardPostPacket
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let rawPacket: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoredTombstone {
|
||||||
|
let tombstone: BoardTombstonePacket
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let rawPacket: Data
|
||||||
|
let retainUntil: UInt64
|
||||||
|
/// True when no matching post was known at ingest time; only these
|
||||||
|
/// count against the orphan caps.
|
||||||
|
let isOrphan: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk entry: the raw signed packet, plus the retention deadline for
|
||||||
|
/// tombstones (derived from the deleted post's original expiry, which is
|
||||||
|
/// no longer recoverable once the post is gone).
|
||||||
|
private struct PersistedEntry: Codable {
|
||||||
|
let packet: Data
|
||||||
|
let retainUntil: UInt64?
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = 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")
|
||||||
|
private let fileURL: URL?
|
||||||
|
private let now: () -> Date
|
||||||
|
|
||||||
|
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||||
|
/// when `persistsToDisk` is false.
|
||||||
|
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||||
|
self.now = now
|
||||||
|
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ingest
|
||||||
|
|
||||||
|
/// Ingest a board packet whose payload decodes to `wire`. The caller must
|
||||||
|
/// have verified the wire signature already (`BoardWire.verifySignature`).
|
||||||
|
@discardableResult
|
||||||
|
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
|
||||||
|
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
|
||||||
|
let nowMs = currentMs()
|
||||||
|
return queue.sync {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reads
|
||||||
|
|
||||||
|
/// Live posts scoped to one board (geohash, or "" for the mesh board).
|
||||||
|
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||||
|
let nowMs = currentMs()
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(nowMs: nowMs)
|
||||||
|
return posts.map(\.post).filter { $0.geohash == geohash }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
|
||||||
|
func syncCandidates() -> [BitchatPacket] {
|
||||||
|
let nowMs = currentMs()
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(nowMs: nowMs)
|
||||||
|
return posts.map(\.packet) + tombstones.map(\.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Maintenance
|
||||||
|
|
||||||
|
/// Panic wipe: drop all board data from memory and disk.
|
||||||
|
func wipe() {
|
||||||
|
queue.sync {
|
||||||
|
posts.removeAll()
|
||||||
|
tombstones.removeAll()
|
||||||
|
if let fileURL {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
publishSnapshotLocked()
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.didWipe.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals (call only on `queue`)
|
||||||
|
|
||||||
|
private func ingestLocked(
|
||||||
|
_ wire: BoardWire,
|
||||||
|
packet: BitchatPacket,
|
||||||
|
rawPacket: Data,
|
||||||
|
nowMs: UInt64,
|
||||||
|
retainUntilOverride: UInt64? = nil
|
||||||
|
) -> BoardIngestResult {
|
||||||
|
pruneExpiredLocked(nowMs: nowMs)
|
||||||
|
switch wire {
|
||||||
|
case .post(let post):
|
||||||
|
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||||
|
case .tombstone(let tombstone):
|
||||||
|
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
|
||||||
|
guard post.expiresAt > nowMs else { return .rejected }
|
||||||
|
// Receive-time sanity (this is the single chokepoint for radio, sync,
|
||||||
|
// and disk restores): the decoder only enforces the createdAt to
|
||||||
|
// expiresAt span, so a forged future createdAt would sort ahead of
|
||||||
|
// honest posts and hold a store slot without ever pruning.
|
||||||
|
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
|
||||||
|
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
|
||||||
|
return .rejected
|
||||||
|
}
|
||||||
|
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
|
||||||
|
return .rejected
|
||||||
|
}
|
||||||
|
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
|
||||||
|
|
||||||
|
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
|
||||||
|
|
||||||
|
// Per-author cap, then global cap; oldest posts are evicted first.
|
||||||
|
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
|
||||||
|
if authorPosts.count > Limits.maxPostsPerAuthor {
|
||||||
|
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
|
||||||
|
}
|
||||||
|
if posts.count > Limits.maxPosts {
|
||||||
|
evictOldestLocked(from: posts, keep: Limits.maxPosts)
|
||||||
|
}
|
||||||
|
publishSnapshotLocked()
|
||||||
|
// Even when the new post itself was the eviction victim it stays
|
||||||
|
// valid mesh-wide; peers with room should still receive it.
|
||||||
|
return .accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ingestTombstoneLocked(
|
||||||
|
_ tombstone: BoardTombstonePacket,
|
||||||
|
packet: BitchatPacket,
|
||||||
|
rawPacket: Data,
|
||||||
|
nowMs: UInt64,
|
||||||
|
retainUntilOverride: UInt64? = nil
|
||||||
|
) -> BoardIngestResult {
|
||||||
|
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
|
||||||
|
|
||||||
|
// Cap retention by both the claimed deletion time (so a doctored file
|
||||||
|
// cannot pin a tombstone past any legal expiry) and the receive time:
|
||||||
|
// deletedAt is sender-chosen, so a far-future value must not retain
|
||||||
|
// the tombstone longer than any post still able to arrive could live.
|
||||||
|
let maxRetain = min(
|
||||||
|
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
|
||||||
|
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
|
||||||
|
)
|
||||||
|
let retainUntil: UInt64
|
||||||
|
let isOrphan: Bool
|
||||||
|
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
|
||||||
|
let target = posts[index].post
|
||||||
|
// Only the author's key can delete: the tombstone signature was
|
||||||
|
// already verified against its embedded key, so it suffices to
|
||||||
|
// require that key to be the post's author key.
|
||||||
|
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
|
||||||
|
retainUntil = target.expiresAt
|
||||||
|
isOrphan = false
|
||||||
|
posts.remove(at: index)
|
||||||
|
publishSnapshotLocked()
|
||||||
|
} else if let retainUntilOverride {
|
||||||
|
// Restored from disk: the post is long gone, so trust the
|
||||||
|
// retention deadline recorded when the delete was first applied.
|
||||||
|
// Orphans were already capped when first ingested off the air.
|
||||||
|
retainUntil = min(retainUntilOverride, maxRetain)
|
||||||
|
isOrphan = false
|
||||||
|
} else {
|
||||||
|
// Post unknown (tombstone raced ahead); keep it around so the
|
||||||
|
// post is suppressed if it arrives later.
|
||||||
|
retainUntil = maxRetain
|
||||||
|
isOrphan = true
|
||||||
|
}
|
||||||
|
guard retainUntil > nowMs else { return .rejected }
|
||||||
|
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
|
||||||
|
if isOrphan {
|
||||||
|
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
|
||||||
|
}
|
||||||
|
// Like posts, a locally evicted tombstone stays valid mesh-wide.
|
||||||
|
return .accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orphan tombstones reference posts we never saw, so a peer can mint
|
||||||
|
/// unlimited valid ones for random IDs; bound them per author and
|
||||||
|
/// globally, evicting the oldest received first (array order).
|
||||||
|
private func enforceOrphanTombstoneCapsLocked(author: Data) {
|
||||||
|
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
|
||||||
|
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
|
||||||
|
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
|
||||||
|
}
|
||||||
|
let orphans = tombstones.filter(\.isOrphan)
|
||||||
|
if orphans.count > Limits.maxOrphanTombstones {
|
||||||
|
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
|
||||||
|
guard !victims.isEmpty else { return }
|
||||||
|
let victimIDs = Set(victims.map { $0.tombstone.postID })
|
||||||
|
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
|
||||||
|
let victims = candidates
|
||||||
|
.sorted { $0.post.createdAt < $1.post.createdAt }
|
||||||
|
.prefix(max(0, candidates.count - keep))
|
||||||
|
guard !victims.isEmpty else { return }
|
||||||
|
let victimIDs = Set(victims.map { $0.post.postID })
|
||||||
|
posts.removeAll { victimIDs.contains($0.post.postID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruneExpiredLocked(nowMs: UInt64) {
|
||||||
|
let postsBefore = posts.count
|
||||||
|
posts.removeAll { $0.post.expiresAt <= nowMs }
|
||||||
|
tombstones.removeAll { $0.retainUntil <= nowMs }
|
||||||
|
if posts.count != postsBefore {
|
||||||
|
publishSnapshotLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publishSnapshotLocked() {
|
||||||
|
let snapshot = posts.map(\.post)
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.postsSnapshot = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentMs() -> UInt64 {
|
||||||
|
UInt64(max(0, now().timeIntervalSince1970) * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func persistLocked() {
|
||||||
|
guard let fileURL else { return }
|
||||||
|
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
|
||||||
|
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
|
||||||
|
do {
|
||||||
|
if payloads.isEmpty {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(payloads)
|
||||||
|
var options: Data.WritingOptions = [.atomic]
|
||||||
|
#if os(iOS)
|
||||||
|
options.insert(.completeFileProtection)
|
||||||
|
#endif
|
||||||
|
try data.write(to: fileURL, options: options)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to persist board store: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFromDisk() {
|
||||||
|
guard let fileURL,
|
||||||
|
let data = try? Data(contentsOf: fileURL),
|
||||||
|
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let nowMs = currentMs()
|
||||||
|
queue.sync {
|
||||||
|
for entry in payloads {
|
||||||
|
guard let packet = BitchatPacket.from(entry.packet),
|
||||||
|
packet.type == MessageType.boardPost.rawValue,
|
||||||
|
let wire = BoardWire.decode(from: packet.payload),
|
||||||
|
wire.verifySignature() else { continue }
|
||||||
|
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
|
||||||
|
}
|
||||||
|
publishSnapshotLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("board", isDirectory: true)
|
||||||
|
.appendingPathComponent("posts.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
/// When the notice fades (board expiry or a note's NIP-40 tag, as dead
|
||||||
|
/// drops carry). Nil means it only ages out of the relay window.
|
||||||
|
let expiresAt: Date?
|
||||||
|
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
|
||||||
|
expiresAt = post.expiresAt > 0
|
||||||
|
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
|
||||||
|
: nil
|
||||||
|
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 = note.isUrgent
|
||||||
|
expiresAt = note.expiresAt
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
//
|
||||||
|
// CashuTokenDecoder.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
|
||||||
|
// base64url CBOR) just far enough to summarize them for the UI: total
|
||||||
|
// amount, unit, mint host, and memo. The app never contacts a mint — tokens
|
||||||
|
// are bearer strings and redemption is delegated to an external wallet.
|
||||||
|
//
|
||||||
|
// This parses attacker-controlled message content, so every path is
|
||||||
|
// bounds-checked, size-capped, and returns nil instead of trapping.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CashuTokenDecoder {
|
||||||
|
|
||||||
|
struct TokenInfo: Equatable {
|
||||||
|
/// Token serialization version: "A" (JSON) or "B" (CBOR).
|
||||||
|
let version: String
|
||||||
|
/// Sum of all proof amounts; nil when no valid amounts were found.
|
||||||
|
let amount: Int?
|
||||||
|
/// Currency unit as declared by the token (commonly "sat"), if any.
|
||||||
|
let unit: String?
|
||||||
|
/// Host of the (first) mint URL, for display.
|
||||||
|
let mintHost: String?
|
||||||
|
/// Optional sender memo, sanitized for display.
|
||||||
|
let memo: String?
|
||||||
|
|
||||||
|
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
|
||||||
|
var displayAmount: String? {
|
||||||
|
amount.map { "\($0) \(unit ?? "sat")" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upper bound on accepted token length in characters. Real tokens are a
|
||||||
|
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
|
||||||
|
static let maxTokenLength = 60_000
|
||||||
|
/// Per-proof and total amount sanity caps (order of total sats in existence).
|
||||||
|
private static let maxAmount: Int64 = 2_100_000_000_000_000
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be
|
||||||
|
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
|
||||||
|
/// input doesn't look like a Cashu token at all.
|
||||||
|
static func bareToken(from raw: String) -> String? {
|
||||||
|
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lower = token.lowercased()
|
||||||
|
if lower.hasPrefix("cashu://") {
|
||||||
|
token = String(token.dropFirst(8))
|
||||||
|
} else if lower.hasPrefix("cashu:") {
|
||||||
|
token = String(token.dropFirst(6))
|
||||||
|
}
|
||||||
|
if token.contains("%"), let decoded = token.removingPercentEncoding {
|
||||||
|
token = decoded
|
||||||
|
}
|
||||||
|
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
|
||||||
|
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
|
||||||
|
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
|
||||||
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
|
||||||
|
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
|
||||||
|
///
|
||||||
|
/// In the default (permissive) mode this is for *rendering*: V3 tokens
|
||||||
|
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
|
||||||
|
/// returns a generic `TokenInfo` (version "B", no amount) because the
|
||||||
|
/// payload may use encodings this minimal reader doesn't support — an
|
||||||
|
/// unknown chip is fine for display.
|
||||||
|
///
|
||||||
|
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
|
||||||
|
/// fallback: the token must cleanly decode to a known version *and* carry
|
||||||
|
/// a positive amount, otherwise this returns nil. This stops base64 junk
|
||||||
|
/// and truncated V4 tokens from being relayed as if they were valid money.
|
||||||
|
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
|
||||||
|
guard let token = bareToken(from: raw) else { return nil }
|
||||||
|
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
|
||||||
|
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let info: TokenInfo?
|
||||||
|
switch version {
|
||||||
|
case "A":
|
||||||
|
info = decodeV3(payload)
|
||||||
|
case "B":
|
||||||
|
if let walked = decodeV4(payload) {
|
||||||
|
info = walked
|
||||||
|
} else if strict {
|
||||||
|
// Couldn't cleanly walk the CBOR — refuse to send it.
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let info else { return nil }
|
||||||
|
if strict {
|
||||||
|
// A sendable token must resolve to a positive, sane amount.
|
||||||
|
guard let amount = info.amount, amount > 0 else { return nil }
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Base64url
|
||||||
|
|
||||||
|
private static func base64URLDecode(_ input: String) -> Data? {
|
||||||
|
var s = input
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
// Normalize padding (wallets emit both padded and unpadded forms)
|
||||||
|
s = s.replacingOccurrences(of: "=", with: "")
|
||||||
|
let remainder = s.count % 4
|
||||||
|
if remainder == 1 { return nil }
|
||||||
|
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
|
||||||
|
return Data(base64Encoded: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - V3 (JSON)
|
||||||
|
|
||||||
|
private static func decodeV3(_ payload: Data) -> TokenInfo? {
|
||||||
|
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
|
||||||
|
let entries = obj["token"] as? [[String: Any]],
|
||||||
|
!entries.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var total: Int64 = 0
|
||||||
|
var sawAmount = false
|
||||||
|
var mintHost: String?
|
||||||
|
for entry in entries {
|
||||||
|
if mintHost == nil, let mint = entry["mint"] as? String {
|
||||||
|
mintHost = sanitizedHost(from: mint)
|
||||||
|
}
|
||||||
|
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
|
||||||
|
guard let number = proof["amount"] as? NSNumber else { continue }
|
||||||
|
let value = number.int64Value
|
||||||
|
guard value > 0, value <= maxAmount else { continue }
|
||||||
|
total += value
|
||||||
|
guard total <= maxAmount else { return nil }
|
||||||
|
sawAmount = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TokenInfo(
|
||||||
|
version: "A",
|
||||||
|
amount: sawAmount ? Int(total) : nil,
|
||||||
|
unit: sanitizedUnit(obj["unit"] as? String),
|
||||||
|
mintHost: mintHost,
|
||||||
|
memo: sanitizedMemo(obj["memo"] as? String)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - V4 (CBOR)
|
||||||
|
|
||||||
|
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
|
||||||
|
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] }
|
||||||
|
private static func decodeV4(_ payload: Data) -> TokenInfo? {
|
||||||
|
var reader = CBORReader(data: payload)
|
||||||
|
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
|
||||||
|
var mintHost: String?
|
||||||
|
var unit: String?
|
||||||
|
var memo: String?
|
||||||
|
var total: Int64 = 0
|
||||||
|
var sawAmount = false
|
||||||
|
for (key, value) in pairs {
|
||||||
|
guard case .text(let name) = key else { continue }
|
||||||
|
switch (name, value) {
|
||||||
|
case ("m", .text(let mint)):
|
||||||
|
mintHost = sanitizedHost(from: mint)
|
||||||
|
case ("u", .text(let u)):
|
||||||
|
unit = sanitizedUnit(u)
|
||||||
|
case ("d", .text(let d)):
|
||||||
|
memo = sanitizedMemo(d)
|
||||||
|
case ("t", .array(let groups)):
|
||||||
|
for case .map(let group) in groups {
|
||||||
|
for case (.text("p"), .array(let proofs)) in group {
|
||||||
|
for case .map(let proof) in proofs {
|
||||||
|
for case (.text("a"), .unsigned(let amount)) in proof {
|
||||||
|
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
|
||||||
|
total += Int64(amount)
|
||||||
|
guard total <= maxAmount else { return nil }
|
||||||
|
sawAmount = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TokenInfo(
|
||||||
|
version: "B",
|
||||||
|
amount: sawAmount ? Int(total) : nil,
|
||||||
|
unit: unit,
|
||||||
|
mintHost: mintHost,
|
||||||
|
memo: memo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display Sanitization (values are attacker-controlled)
|
||||||
|
|
||||||
|
private static func sanitizedHost(from mint: String) -> String? {
|
||||||
|
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
|
||||||
|
return String(host.lowercased().prefix(48))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedUnit(_ unit: String?) -> String? {
|
||||||
|
guard let unit, !unit.isEmpty, unit.count <= 12,
|
||||||
|
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedMemo(_ memo: String?) -> String? {
|
||||||
|
guard let memo, memo.count <= 512 else { return nil }
|
||||||
|
let stripped = CharacterSet.controlCharacters.union(.newlines)
|
||||||
|
var cleaned = ""
|
||||||
|
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
|
||||||
|
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !cleaned.isEmpty else { return nil }
|
||||||
|
return String(cleaned.prefix(80))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Minimal CBOR Reader
|
||||||
|
|
||||||
|
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
|
||||||
|
/// depth, item count, and byte length; indefinite-length items and anything
|
||||||
|
/// else exotic make the parse fail (the caller degrades to a generic chip).
|
||||||
|
private struct CBORReader {
|
||||||
|
indirect enum Value {
|
||||||
|
case unsigned(UInt64)
|
||||||
|
case text(String)
|
||||||
|
case array([Value])
|
||||||
|
case map([(Value, Value)])
|
||||||
|
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats…)
|
||||||
|
case opaque
|
||||||
|
}
|
||||||
|
|
||||||
|
private let bytes: [UInt8]
|
||||||
|
private var index = 0
|
||||||
|
/// Total item budget so hostile nesting can't run away.
|
||||||
|
private var itemBudget = 50_000
|
||||||
|
private static let maxDepth = 16
|
||||||
|
private static let maxContainerCount: UInt64 = 10_000
|
||||||
|
|
||||||
|
init(data: Data) {
|
||||||
|
bytes = [UInt8](data)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func parseValue(depth: Int) -> Value? {
|
||||||
|
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
|
||||||
|
itemBudget -= 1
|
||||||
|
guard let (major, argument) = readHead() else { return nil }
|
||||||
|
switch major {
|
||||||
|
case 0: // unsigned int
|
||||||
|
return .unsigned(argument)
|
||||||
|
case 1: // negative int (argument already consumed)
|
||||||
|
return .opaque
|
||||||
|
case 2: // byte string
|
||||||
|
return readBytes(count: argument) != nil ? .opaque : nil
|
||||||
|
case 3: // text string
|
||||||
|
guard let raw = readBytes(count: argument) else { return nil }
|
||||||
|
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
|
||||||
|
case 4: // array
|
||||||
|
guard argument <= Self.maxContainerCount else { return nil }
|
||||||
|
var items: [Value] = []
|
||||||
|
items.reserveCapacity(Int(min(argument, 64)))
|
||||||
|
for _ in 0..<argument {
|
||||||
|
guard let item = parseValue(depth: depth + 1) else { return nil }
|
||||||
|
items.append(item)
|
||||||
|
}
|
||||||
|
return .array(items)
|
||||||
|
case 5: // map
|
||||||
|
guard argument <= Self.maxContainerCount else { return nil }
|
||||||
|
var pairs: [(Value, Value)] = []
|
||||||
|
pairs.reserveCapacity(Int(min(argument, 64)))
|
||||||
|
for _ in 0..<argument {
|
||||||
|
guard let key = parseValue(depth: depth + 1),
|
||||||
|
let value = parseValue(depth: depth + 1) else { return nil }
|
||||||
|
pairs.append((key, value))
|
||||||
|
}
|
||||||
|
return .map(pairs)
|
||||||
|
case 6: // tag: skip the tag number, parse the tagged value
|
||||||
|
return parseValue(depth: depth + 1)
|
||||||
|
case 7: // simple values / floats (payload consumed by readHead)
|
||||||
|
return .opaque
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
|
||||||
|
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
|
||||||
|
guard index < bytes.count else { return nil }
|
||||||
|
let head = bytes[index]
|
||||||
|
index += 1
|
||||||
|
let major = head >> 5
|
||||||
|
let info = head & 0x1F
|
||||||
|
switch info {
|
||||||
|
case 0...23:
|
||||||
|
return (major, UInt64(info))
|
||||||
|
case 24:
|
||||||
|
return readUInt(width: 1).map { (major, $0) }
|
||||||
|
case 25:
|
||||||
|
return readUInt(width: 2).map { (major, $0) }
|
||||||
|
case 26:
|
||||||
|
return readUInt(width: 4).map { (major, $0) }
|
||||||
|
case 27:
|
||||||
|
return readUInt(width: 8).map { (major, $0) }
|
||||||
|
default: // 28-30 reserved, 31 indefinite
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func readUInt(width: Int) -> UInt64? {
|
||||||
|
guard bytes.count - index >= width else { return nil }
|
||||||
|
var value: UInt64 = 0
|
||||||
|
for _ in 0..<width {
|
||||||
|
value = (value << 8) | UInt64(bytes[index])
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func readBytes(count: UInt64) -> [UInt8]? {
|
||||||
|
guard count <= UInt64(bytes.count - index) else { return nil }
|
||||||
|
let length = Int(count)
|
||||||
|
let slice = Array(bytes[index..<(index + length)])
|
||||||
|
index += length
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,17 @@ struct CommandGeoParticipant {
|
|||||||
let displayName: String
|
let displayName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The conversation a command was typed into, captured when the command is
|
||||||
|
/// issued so deferred output (e.g. an async /ping result, which can arrive
|
||||||
|
/// many seconds later) lands there even if the user switches chats first.
|
||||||
|
enum CommandOutputDestination: Equatable {
|
||||||
|
/// The #mesh public timeline. Commands that defer output (/ping) are
|
||||||
|
/// mesh-only, so a non-DM origin is always the mesh timeline.
|
||||||
|
case meshTimeline
|
||||||
|
/// The private chat that was open when the command was typed.
|
||||||
|
case privateChat(PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Protocol defining what CommandProcessor needs from its context.
|
/// Protocol defining what CommandProcessor needs from its context.
|
||||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -45,15 +56,33 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||||
func clearPrivateChat(_ peerID: PeerID)
|
func clearPrivateChat(_ peerID: PeerID)
|
||||||
func sendPublicRaw(_ content: String)
|
func sendPublicRaw(_ content: String)
|
||||||
|
/// Sends a normal public message (with local echo) to the active channel.
|
||||||
|
func sendPublicMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: - System Messages
|
// MARK: - System Messages
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||||
func addPublicSystemMessage(_ content: String)
|
func addPublicSystemMessage(_ content: String)
|
||||||
|
/// The conversation the user is typing into right now. Commands that
|
||||||
|
/// finish asynchronously capture this BEFORE starting async work, so a
|
||||||
|
/// chat switch cannot misroute their deferred output.
|
||||||
|
func currentCommandDestination() -> CommandOutputDestination
|
||||||
|
/// Routes deferred command output (e.g. an async /ping result) into the
|
||||||
|
/// conversation captured when the command was issued.
|
||||||
|
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
|
||||||
|
|
||||||
// MARK: - Favorites
|
// MARK: - Favorites
|
||||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||||
/// real noise key and notifies the peer over mesh or Nostr.
|
/// real noise key and notifies the peer over mesh or Nostr.
|
||||||
func toggleFavorite(peerID: PeerID)
|
func toggleFavorite(peerID: PeerID)
|
||||||
|
|
||||||
|
// MARK: - Groups
|
||||||
|
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
|
||||||
|
// /group subcommands.
|
||||||
|
func groupCreate(named name: String) -> CommandResult
|
||||||
|
func groupInvite(nickname: String) -> CommandResult
|
||||||
|
func groupRemove(nickname: String) -> CommandResult
|
||||||
|
func groupLeave() -> CommandResult
|
||||||
|
func groupList() -> CommandResult
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@@ -100,12 +129,25 @@ final class CommandProcessor {
|
|||||||
return handleBlock(args)
|
return handleBlock(args)
|
||||||
case "/unblock":
|
case "/unblock":
|
||||||
return handleUnblock(args)
|
return handleUnblock(args)
|
||||||
|
case "/group":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||||
|
return handleGroup(args)
|
||||||
case "/fav":
|
case "/fav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: true)
|
return handleFavorite(args, add: true)
|
||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
|
case "/ping":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
||||||
|
return handlePing(args)
|
||||||
|
case "/trace":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||||
|
return handleTrace(args)
|
||||||
|
case "/pay":
|
||||||
|
return handlePay(args)
|
||||||
|
case "/drop":
|
||||||
|
return handleDrop(args)
|
||||||
case "/help":
|
case "/help":
|
||||||
return .success(message: Self.helpText)
|
return .success(message: Self.helpText)
|
||||||
default:
|
default:
|
||||||
@@ -125,9 +167,42 @@ final class CommandProcessor {
|
|||||||
/slap @name — slap with a large trout
|
/slap @name — slap with a large trout
|
||||||
/block @name · /unblock @name
|
/block @name · /unblock @name
|
||||||
/fav @name · /unfav @name — favorites (mesh only)
|
/fav @name · /unfav @name — favorites (mesh only)
|
||||||
|
/group create <name> — start an encrypted group
|
||||||
|
/group invite @name · /group remove @name — manage members (creator)
|
||||||
|
/group leave · /group list — leave or list your groups
|
||||||
|
/ping @name — measure round-trip time (mesh only)
|
||||||
|
/trace @name — estimated mesh path (mesh only)
|
||||||
|
/pay <token> — send a cashu ecash token in this chat
|
||||||
|
/drop <message> — pin a note to this place for 24h (needs location)
|
||||||
/help — this list
|
/help — this list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
/// /drop <text> — a dead drop: pins a note to the current building-level
|
||||||
|
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
|
||||||
|
/// looks at notices (or hits the empty-timeline "notes left here" hint)
|
||||||
|
/// reads it.
|
||||||
|
private func handleDrop(_ args: String) -> CommandResult {
|
||||||
|
guard LocationNotesSettings.enabled else {
|
||||||
|
return .error(message: "location notes are off — enable them in the info screen")
|
||||||
|
}
|
||||||
|
guard let content = args.trimmedOrNilIfEmpty else {
|
||||||
|
return .error(message: "usage: /drop <message>")
|
||||||
|
}
|
||||||
|
let location = LocationChannelManager.shared
|
||||||
|
guard location.permissionState == .authorized else {
|
||||||
|
return .error(message: "leaving a note needs location — enable it in the info screen")
|
||||||
|
}
|
||||||
|
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
|
||||||
|
location.refreshChannels()
|
||||||
|
return .error(message: "still finding this place — try again in a moment")
|
||||||
|
}
|
||||||
|
guard let nickname = contextProvider?.nickname,
|
||||||
|
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
|
||||||
|
return .error(message: "no geo relays reachable — note not left")
|
||||||
|
}
|
||||||
|
return .success(message: "📍 note left here — it fades in 24h")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
@@ -331,6 +406,138 @@ final class CommandProcessor {
|
|||||||
return .error(message: "cannot unblock \(nickname): not found")
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||||
|
|
||||||
|
private func handleGroup(_ args: String) -> CommandResult {
|
||||||
|
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||||
|
guard let subcommand = parts.first else {
|
||||||
|
return .error(message: Self.groupUsage)
|
||||||
|
}
|
||||||
|
let rest = parts.count > 1 ? String(parts[1]) : ""
|
||||||
|
guard let provider = contextProvider else { return .handled }
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "create":
|
||||||
|
return provider.groupCreate(named: rest)
|
||||||
|
case "invite":
|
||||||
|
return provider.groupInvite(nickname: rest)
|
||||||
|
case "remove":
|
||||||
|
return provider.groupRemove(nickname: rest)
|
||||||
|
case "leave":
|
||||||
|
return provider.groupLeave()
|
||||||
|
case "list":
|
||||||
|
return provider.groupList()
|
||||||
|
default:
|
||||||
|
return .error(message: Self.groupUsage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh Diagnostics
|
||||||
|
|
||||||
|
private enum MeshPeerResolution {
|
||||||
|
case resolved(peerID: PeerID, nickname: String)
|
||||||
|
case failed(CommandResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
|
||||||
|
/// rejected — diagnostics measure the BLE mesh, not Nostr.
|
||||||
|
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
||||||
|
let targetName = args.trimmed
|
||||||
|
guard !targetName.isEmpty else {
|
||||||
|
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
||||||
|
}
|
||||||
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||||
|
!peerID.isGeoDM, !peerID.isGeoChat else {
|
||||||
|
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
||||||
|
}
|
||||||
|
return .resolved(peerID: peerID, nickname: nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePing(_ args: String) -> CommandResult {
|
||||||
|
let target: (peerID: PeerID, nickname: String)
|
||||||
|
switch resolveMeshPeer(args, command: "ping") {
|
||||||
|
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||||
|
case .failed(let result): return result
|
||||||
|
}
|
||||||
|
|
||||||
|
let nickname = target.nickname
|
||||||
|
let currentProvider = contextProvider
|
||||||
|
// Capture the origin conversation now: the pong can arrive up to
|
||||||
|
// meshPingTimeoutSeconds later, and reading the selected chat at
|
||||||
|
// callback time would misroute the result after a chat switch.
|
||||||
|
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
|
||||||
|
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
||||||
|
let provider = currentProvider
|
||||||
|
guard let result else {
|
||||||
|
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let hopText: String = result.hops.map { hops in
|
||||||
|
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
|
||||||
|
} ?? ""
|
||||||
|
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
||||||
|
}
|
||||||
|
return .success(message: "pinging \(nickname)…")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTrace(_ args: String) -> CommandResult {
|
||||||
|
let target: (peerID: PeerID, nickname: String)
|
||||||
|
switch resolveMeshPeer(args, command: "trace") {
|
||||||
|
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||||
|
case .failed(let result): return result
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let mesh = meshService,
|
||||||
|
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
|
||||||
|
return .success(message: "no known path to \(target.nickname)")
|
||||||
|
}
|
||||||
|
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||||
|
// present it as an estimate.
|
||||||
|
let hopNames = intermediates.map { hop in
|
||||||
|
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
||||||
|
}
|
||||||
|
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||||
|
let hops = intermediates.count + 1
|
||||||
|
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
||||||
|
/// the message body in the current chat. Cashu tokens are bearer
|
||||||
|
/// instruments (whoever redeems first gets the funds), so posting one to
|
||||||
|
/// a public channel requires an explicit `/pay <token> public` confirm.
|
||||||
|
/// The app never contacts a mint; it only relays the string.
|
||||||
|
private func handlePay(_ args: String) -> CommandResult {
|
||||||
|
var parts = args.trimmed.split(separator: " ").map(String.init)
|
||||||
|
guard !parts.isEmpty else {
|
||||||
|
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
|
||||||
|
if confirmedPublic { parts.removeLast() }
|
||||||
|
|
||||||
|
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
||||||
|
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
||||||
|
}
|
||||||
|
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
||||||
|
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = info.displayAmount ?? "a cashu token"
|
||||||
|
|
||||||
|
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||||
|
contextProvider?.sendPrivateMessage(token, to: peerID)
|
||||||
|
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard confirmedPublic else {
|
||||||
|
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
|
||||||
|
}
|
||||||
|
|
||||||
|
contextProvider?.sendPublicMessage(token)
|
||||||
|
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
|
||||||
|
}
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ final class CourierStore {
|
|||||||
var sprayedTo: Set<Data>
|
var sprayedTo: Set<Data>
|
||||||
/// Last speculative multi-hop handover toward a relayed announce.
|
/// Last speculative multi-hop handover toward a relayed announce.
|
||||||
var lastRemoteHandoverAt: Date?
|
var lastRemoteHandoverAt: Date?
|
||||||
|
/// Last publish of this envelope as a bridge courier drop on relays.
|
||||||
|
var lastBridgePublishAt: Date?
|
||||||
|
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||||
|
let prekeyID: UInt32?
|
||||||
|
|
||||||
var envelope: CourierEnvelope {
|
var envelope: CourierEnvelope {
|
||||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -56,7 +60,9 @@ final class CourierStore {
|
|||||||
tier: CourierDepositTier,
|
tier: CourierDepositTier,
|
||||||
copies: UInt8,
|
copies: UInt8,
|
||||||
sprayedTo: Set<Data> = [],
|
sprayedTo: Set<Data> = [],
|
||||||
lastRemoteHandoverAt: Date? = nil
|
lastRemoteHandoverAt: Date? = nil,
|
||||||
|
lastBridgePublishAt: Date? = nil,
|
||||||
|
prekeyID: UInt32? = nil
|
||||||
) {
|
) {
|
||||||
self.recipientTag = recipientTag
|
self.recipientTag = recipientTag
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
@@ -67,6 +73,8 @@ final class CourierStore {
|
|||||||
self.copies = copies
|
self.copies = copies
|
||||||
self.sprayedTo = sprayedTo
|
self.sprayedTo = sprayedTo
|
||||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||||
|
self.lastBridgePublishAt = lastBridgePublishAt
|
||||||
|
self.prekeyID = prekeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files written before tiers/spray lack the newer fields; treat that
|
// Files written before tiers/spray lack the newer fields; treat that
|
||||||
@@ -82,6 +90,8 @@ final class CourierStore {
|
|||||||
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
||||||
|
lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt)
|
||||||
|
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +196,8 @@ final class CourierStore {
|
|||||||
depositorNoiseKey: depositorNoiseKey,
|
depositorNoiseKey: depositorNoiseKey,
|
||||||
storedAt: date,
|
storedAt: date,
|
||||||
tier: tier,
|
tier: tier,
|
||||||
copies: envelope.copies
|
copies: envelope.copies,
|
||||||
|
prekeyID: envelope.prekeyID
|
||||||
))
|
))
|
||||||
persistLocked()
|
persistLocked()
|
||||||
return true
|
return true
|
||||||
@@ -236,6 +247,30 @@ final class CourierStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Envelopes to park on relays as bridge courier drops. Non-destructive
|
||||||
|
/// like remote handover — the relay copy is speculative, so the carried
|
||||||
|
/// copy stays until direct handover or expiry. The per-envelope cooldown
|
||||||
|
/// keeps relay churn from republishing the same mail; publishing relays
|
||||||
|
/// dedup identical events by ID anyway.
|
||||||
|
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
|
let date = now()
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
var matched: [CourierEnvelope] = []
|
||||||
|
for index in envelopes.indices {
|
||||||
|
if let last = envelopes[index].lastBridgePublishAt,
|
||||||
|
date.timeIntervalSince(last) < cooldown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
envelopes[index].lastBridgePublishAt = date
|
||||||
|
// The relay copy carries no spray budget.
|
||||||
|
matched.append(envelopes[index].envelope.withCopies(1))
|
||||||
|
}
|
||||||
|
if !matched.isEmpty { persistLocked() }
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Spray-and-wait (on encountering another courier)
|
// MARK: - Spray-and-wait (on encountering another courier)
|
||||||
|
|
||||||
/// Envelopes to re-deposit with a courier we just encountered, each with
|
/// Envelopes to re-deposit with a courier we just encountered, each with
|
||||||
@@ -266,14 +301,6 @@ final class CourierStore {
|
|||||||
|
|
||||||
// MARK: - Maintenance
|
// MARK: - Maintenance
|
||||||
|
|
||||||
func pruneExpired() {
|
|
||||||
let date = now()
|
|
||||||
queue.sync {
|
|
||||||
pruneExpiredLocked(at: date)
|
|
||||||
persistLocked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panic wipe: drop all carried mail from memory and disk.
|
/// Panic wipe: drop all carried mail from memory and disk.
|
||||||
func wipe() {
|
func wipe() {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ final class StoreAndForwardMetrics {
|
|||||||
SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
|
SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshot() -> [String: Int] {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Included in the panic wipe alongside the stores it describes.
|
/// Included in the panic wipe alongside the stores it describes.
|
||||||
func reset() {
|
func reset() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
|
|||||||
@@ -34,23 +34,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
/// Default keychain for the `shared` singleton. Under test this is an
|
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||||
/// in-memory keychain so touching `shared` never blocks on securityd
|
|
||||||
/// (`SecItemCopyMatching` can hang in test environments) and never reads
|
|
||||||
/// or writes the developer's real keychain. Production behavior is
|
|
||||||
/// unchanged. Tests that need their own instance keep injecting a mock
|
|
||||||
/// via `init(keychain:)`.
|
|
||||||
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
|
|
||||||
// PreviewKeychainManager lives in _PreviewHelpers, a development
|
|
||||||
// asset excluded from archive builds — release code must not
|
|
||||||
// reference it. Tests always run Debug, so the guard is lossless.
|
|
||||||
#if DEBUG
|
|
||||||
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
|
|
||||||
#endif
|
|
||||||
return KeychainManager()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// BoundedIDSet.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
||||||
|
/// evicted when full. Shared by the gateway and bridge loop-prevention
|
||||||
|
/// caches.
|
||||||
|
struct BoundedIDSet {
|
||||||
|
private var members: Set<String> = []
|
||||||
|
private var order: [String] = []
|
||||||
|
let capacity: Int
|
||||||
|
|
||||||
|
init(capacity: Int) {
|
||||||
|
self.capacity = capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ id: String) -> Bool {
|
||||||
|
members.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns false when the ID was already present.
|
||||||
|
@discardableResult
|
||||||
|
mutating func insert(_ id: String) -> Bool {
|
||||||
|
guard members.insert(id).inserted else { return false }
|
||||||
|
order.append(id)
|
||||||
|
if order.count > capacity {
|
||||||
|
members.remove(order.removeFirst())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
//
|
||||||
|
// BridgeCourierService.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 Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Courier delivery over the internet bridge: sealed courier envelopes are
|
||||||
|
/// parked on relays as kind-1401 "drops" tagged with their rotating
|
||||||
|
/// recipient tag, so delivery stops requiring a physical courier to bump
|
||||||
|
/// into the recipient.
|
||||||
|
///
|
||||||
|
/// Three duties, all gated on the bridge toggle:
|
||||||
|
/// - Sender: when the message router seals mail for an unreachable peer, a
|
||||||
|
/// copy is published as a drop (queued until relays connect). The drop is
|
||||||
|
/// signed with a fresh throwaway key per publish — the envelope
|
||||||
|
/// authenticates its sender internally via Noise-X, and a stable publisher
|
||||||
|
/// key would leak courier traffic patterns to relays.
|
||||||
|
/// - Recipient: subscribes for its own candidate tags (adjacent UTC days)
|
||||||
|
/// and opens matching drops directly.
|
||||||
|
/// - Gateway (bridge + gateway toggles): additionally watches the tags of
|
||||||
|
/// verified local mesh peers and hands matching drops to them as directed
|
||||||
|
/// courier packets, so mesh-only recipients are served too.
|
||||||
|
///
|
||||||
|
/// Privacy: a drop reveals to relays only that "someone" is messaging "some
|
||||||
|
/// 16-byte day-rotating tag". Only parties who already know the recipient's
|
||||||
|
/// Noise static key can compute the tag; the payload is an opaque Noise-X
|
||||||
|
/// seal. Duplicate deliveries (drop + physical courier + direct link) are
|
||||||
|
/// absorbed downstream by message-ID dedup.
|
||||||
|
@MainActor
|
||||||
|
final class BridgeCourierService: ObservableObject {
|
||||||
|
enum Limits {
|
||||||
|
/// Drops waiting for relay connectivity (bounded, drop-oldest).
|
||||||
|
static let maxPendingDrops = 20
|
||||||
|
/// Republish cooldown for gateway-held envelopes.
|
||||||
|
static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60
|
||||||
|
/// Local peers a gateway watches drops for (x3 candidate tags each).
|
||||||
|
static let maxWatchedPeers = 16
|
||||||
|
/// Tag-set refresh cadence (also covers UTC day rollover).
|
||||||
|
static let refreshIntervalSeconds: TimeInterval = 30 * 60
|
||||||
|
/// Minimum spacing for announce-driven refreshes.
|
||||||
|
static let announceRefreshDebounceSeconds: TimeInterval = 60
|
||||||
|
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
|
||||||
|
static let maxDropEnvelopeBytes = 20 * 1024
|
||||||
|
static let maxTrackedIDs = 512
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = BridgeCourierService()
|
||||||
|
|
||||||
|
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||||
|
|
||||||
|
var bridgeEnabled: (@MainActor () -> Bool)?
|
||||||
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
|
/// Publishes a signed drop event to the default (DM) relays.
|
||||||
|
var publishEvent: (@MainActor (NostrEvent) -> Void)?
|
||||||
|
/// (Re)opens the drop subscription for the given hex tags.
|
||||||
|
var openSubscription: (@MainActor ([String]) -> Void)?
|
||||||
|
var closeSubscription: (@MainActor () -> Void)?
|
||||||
|
/// Our own Noise static public key.
|
||||||
|
var myNoiseKey: (@MainActor () -> Data?)?
|
||||||
|
/// Verified reachable local peers with known Noise keys.
|
||||||
|
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
|
||||||
|
/// Seals content into a carry-only envelope for a recipient key.
|
||||||
|
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
|
||||||
|
/// Opens a drop addressed to us (tag verified inside).
|
||||||
|
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
|
||||||
|
/// Hands a drop to a matching local peer as a directed courier packet.
|
||||||
|
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
|
||||||
|
/// Held envelopes eligible for (re)publish, honoring the cooldown.
|
||||||
|
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
|
||||||
|
/// Timer injection for tests; nil arms a real `Task`.
|
||||||
|
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
private(set) var myTagsHex: Set<String> = []
|
||||||
|
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
|
||||||
|
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
|
||||||
|
/// Message IDs already published as drops (sender-side dedup).
|
||||||
|
private var publishedDropKeys: BoundedIDSet
|
||||||
|
/// Drop event IDs already handled (multi-relay dedup).
|
||||||
|
private var seenDropEventIDs: BoundedIDSet
|
||||||
|
private var subscriptionOpen = false
|
||||||
|
private var lastSubscribedTags: Set<String> = []
|
||||||
|
private var refreshTimerArmed = false
|
||||||
|
private var lastAnnounceRefresh = Date.distantPast
|
||||||
|
|
||||||
|
private let now: () -> Date
|
||||||
|
|
||||||
|
init(now: @escaping () -> Date = Date.init) {
|
||||||
|
self.now = now
|
||||||
|
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
||||||
|
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sender role
|
||||||
|
|
||||||
|
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||||
|
/// relay drop. Called by the message router alongside physical courier
|
||||||
|
/// deposits; idempotent per message ID. Returns true when a fresh drop
|
||||||
|
/// was sealed (published now or queued for the next relay connection) —
|
||||||
|
/// the router marks the message "carried" so the sender sees progress.
|
||||||
|
@discardableResult
|
||||||
|
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
|
||||||
|
guard bridgeEnabled?() ?? false else { return false }
|
||||||
|
guard !publishedDropKeys.contains(messageID) else { return false }
|
||||||
|
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
|
||||||
|
publishedDropKeys.insert(messageID)
|
||||||
|
publishDrop(envelope)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||||
|
/// honoring the per-envelope cooldown.
|
||||||
|
func publishHeldEnvelopes() {
|
||||||
|
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
|
||||||
|
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
|
||||||
|
publishDrop(envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publishDrop(_ envelope: CourierEnvelope) {
|
||||||
|
guard let encoded = envelope.encode(),
|
||||||
|
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||||
|
!envelope.isExpired else { return }
|
||||||
|
guard relaysConnected?() ?? false else {
|
||||||
|
pendingDrops.append((envelope, nil))
|
||||||
|
if pendingDrops.count > Limits.maxPendingDrops {
|
||||||
|
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let identity = Self.makeThrowawayIdentity(),
|
||||||
|
let event = try? NostrProtocol.createCourierDropEvent(
|
||||||
|
envelope: encoded,
|
||||||
|
recipientTagHex: envelope.recipientTag.hexEncodedString(),
|
||||||
|
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
|
||||||
|
senderIdentity: identity
|
||||||
|
) else {
|
||||||
|
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishEvent?(event)
|
||||||
|
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops queued while relays were unreachable publish on reconnect.
|
||||||
|
func flushPendingDrops() {
|
||||||
|
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
|
||||||
|
let queued = pendingDrops
|
||||||
|
pendingDrops.removeAll()
|
||||||
|
for item in queued {
|
||||||
|
publishDrop(item.envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription (recipient + gateway watch)
|
||||||
|
|
||||||
|
/// Recomputes the watched tag set and (re)opens the subscription.
|
||||||
|
/// Call on toggle changes, relay connectivity changes, and periodically
|
||||||
|
/// (tags rotate daily); idempotent.
|
||||||
|
func refresh() {
|
||||||
|
armRefreshTimerIfNeeded()
|
||||||
|
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
|
||||||
|
if subscriptionOpen {
|
||||||
|
closeSubscription?()
|
||||||
|
subscriptionOpen = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let date = now()
|
||||||
|
if let myKey = myNoiseKey?() {
|
||||||
|
myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() })
|
||||||
|
} else {
|
||||||
|
myTagsHex = []
|
||||||
|
}
|
||||||
|
// While bridging with internet, every device watches drops for its
|
||||||
|
// verified local peers — the single-switch analogue of gateway duty.
|
||||||
|
let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers)
|
||||||
|
watchedPeerTags = peers.map { peer in
|
||||||
|
(peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() }))
|
||||||
|
}
|
||||||
|
let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex))
|
||||||
|
guard !allTags.isEmpty else {
|
||||||
|
if subscriptionOpen {
|
||||||
|
closeSubscription?()
|
||||||
|
subscriptionOpen = false
|
||||||
|
lastSubscribedTags = []
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Resubscribe only when the watched set actually changed — refresh
|
||||||
|
// fires on every verified announce (field logs showed the drop
|
||||||
|
// subscription rebuilt every ~60s for an unchanged tag set).
|
||||||
|
if !subscriptionOpen || allTags != lastSubscribedTags {
|
||||||
|
openSubscription?(allTags.sorted())
|
||||||
|
subscriptionOpen = true
|
||||||
|
lastSubscribedTags = allTags
|
||||||
|
}
|
||||||
|
flushPendingDrops()
|
||||||
|
publishHeldEnvelopes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce-driven refresh, debounced — a newly verified peer should be
|
||||||
|
/// watched promptly, but announce storms must not thrash subscriptions.
|
||||||
|
func refreshAfterVerifiedAnnounce() {
|
||||||
|
guard bridgeEnabled?() ?? false else { return }
|
||||||
|
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
|
||||||
|
lastAnnounceRefresh = now()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func armRefreshTimerIfNeeded() {
|
||||||
|
guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return }
|
||||||
|
refreshTimerArmed = true
|
||||||
|
let fire: @MainActor () -> Void = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.refreshTimerArmed = false
|
||||||
|
self.refresh()
|
||||||
|
}
|
||||||
|
if let scheduleTimer {
|
||||||
|
scheduleTimer(Limits.refreshIntervalSeconds, fire)
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000))
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inbound drops
|
||||||
|
|
||||||
|
/// Entry point for every drop event the subscription delivers (the relay
|
||||||
|
/// manager has already verified the event signature).
|
||||||
|
func handleDropEvent(_ event: NostrEvent) {
|
||||||
|
guard bridgeEnabled?() ?? false else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
|
||||||
|
guard seenDropEventIDs.insert(event.id) else { return }
|
||||||
|
guard let data = Data(base64Encoded: event.content),
|
||||||
|
data.count <= Limits.maxDropEnvelopeBytes,
|
||||||
|
let envelope = CourierEnvelope.decode(data),
|
||||||
|
!envelope.isExpired else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tagHex = envelope.recipientTag.hexEncodedString()
|
||||||
|
// The envelope's own tag must match the event's filterable tag —
|
||||||
|
// otherwise a mislabeled drop could ride a subscription it doesn't
|
||||||
|
// belong to.
|
||||||
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return }
|
||||||
|
|
||||||
|
if myTagsHex.contains(tagHex) {
|
||||||
|
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
|
||||||
|
openEnvelope?(envelope)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
|
||||||
|
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session)
|
||||||
|
deliverToPeer?(envelope, match.peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// A fresh random Nostr identity for signing one drop.
|
||||||
|
static func makeThrowawayIdentity() -> NostrIdentity? {
|
||||||
|
for _ in 0..<10 {
|
||||||
|
var bytes = Data(count: 32)
|
||||||
|
let status = bytes.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
if let identity = try? NostrIdentity(privateKeyData: bytes) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
//
|
||||||
|
// BridgeService.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 Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE
|
||||||
|
/// mesh islands that share a place. While the toggle is on, this device's
|
||||||
|
/// public mesh messages are additionally signed (with a derived, unlinkable
|
||||||
|
/// per-cell Nostr identity) as rendezvous events for the local geohash cell
|
||||||
|
/// and published to the cell's deterministic geo relays — directly when we
|
||||||
|
/// have internet, or deposited with a bridge gateway peer over a directed
|
||||||
|
/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from
|
||||||
|
/// other islands render into the mesh timeline marked as bridged.
|
||||||
|
///
|
||||||
|
/// A device with BOTH this toggle and the gateway toggle on serves the
|
||||||
|
/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts
|
||||||
|
/// remote rendezvous events onto the mesh as `fromBridge` carriers so
|
||||||
|
/// mesh-only peers see across the bridge too.
|
||||||
|
///
|
||||||
|
/// Consent model:
|
||||||
|
/// - Nothing crosses a bridge unless its author signed it for the bridge:
|
||||||
|
/// gateways carry only finished, Schnorr-signed rendezvous events, so a
|
||||||
|
/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message
|
||||||
|
/// "nearby only" flag simply skips composing a rendezvous copy.
|
||||||
|
/// - Receiving over radio (`fromBridge` carriers) is always on — it is
|
||||||
|
/// passive and leaks nothing. Subscribing over the internet (which reveals
|
||||||
|
/// the coarse cell to relays) and publishing both require the toggle.
|
||||||
|
///
|
||||||
|
/// Loop-prevention rules (adapted from `GatewayService`, unit-tested):
|
||||||
|
/// 1. An event learned from a `fromBridge` mesh broadcast is never published
|
||||||
|
/// and never rebroadcast (`meshBroadcastEventIDs`) — a second bridge
|
||||||
|
/// gateway on the same island cannot echo mesh-carried traffic.
|
||||||
|
/// 2. An event this device published (own messages or uplinked deposits,
|
||||||
|
/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on
|
||||||
|
/// this island, so our own relay subscription redelivering it must not
|
||||||
|
/// double BLE airtime.
|
||||||
|
/// 3. A subscription event is rebroadcast at most once
|
||||||
|
/// (`rebroadcastEventIDs`, marked after send), and never when the island
|
||||||
|
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
||||||
|
/// mesh message ID) — remote islands' traffic is the only thing worth
|
||||||
|
/// airtime.
|
||||||
|
/// Receivers additionally dedup by timeline message ID: a bridged copy
|
||||||
|
/// carries the original mesh message ID in its `m` tag, so the store's
|
||||||
|
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
|
||||||
|
///
|
||||||
|
/// All dependencies are closure-injected (repo convention) so the policy
|
||||||
|
/// layer is unit-testable without relays, radios, or CoreLocation.
|
||||||
|
@MainActor
|
||||||
|
final class BridgeService: ObservableObject {
|
||||||
|
enum Limits {
|
||||||
|
/// Uplink deposits held while relays are unreachable.
|
||||||
|
static let maxQueuedUplinks = 20
|
||||||
|
static let maxQueuedUplinksPerDepositor = 5
|
||||||
|
/// Uplink deposits accepted per depositor per minute.
|
||||||
|
static let uplinkEventsPerMinutePerDepositor = 10
|
||||||
|
/// Downlink mesh rebroadcasts per minute — BLE airtime is precious,
|
||||||
|
/// and bridge traffic shares the radio with everything else.
|
||||||
|
static let downlinkEventsPerMinute = 20
|
||||||
|
static let maxPendingDownlinks = 30
|
||||||
|
/// Accepted clock skew for a rendezvous event.
|
||||||
|
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
||||||
|
/// Bounded loop-prevention ID caches (oldest evicted).
|
||||||
|
static let maxTrackedEventIDs = 512
|
||||||
|
/// Presence heartbeat cadence while the bridge is active.
|
||||||
|
static let presenceIntervalSeconds: TimeInterval = 4 * 60
|
||||||
|
/// A rendezvous participant counts toward "via bridge" for this long
|
||||||
|
/// after their last event.
|
||||||
|
static let participantFreshnessSeconds: TimeInterval = 10 * 60
|
||||||
|
/// Content cap, matching the public-message pipeline's own limit.
|
||||||
|
static let maxContentBytes = 16_000
|
||||||
|
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
|
||||||
|
static let cellPrecision = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QueuedUplink {
|
||||||
|
let depositor: PeerID
|
||||||
|
let cell: String
|
||||||
|
let event: NostrEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated rendezvous message ready for the timeline.
|
||||||
|
struct InboundBridgeMessage {
|
||||||
|
let messageID: String
|
||||||
|
let senderNickname: String
|
||||||
|
let senderPubkey: String
|
||||||
|
let content: String
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A person currently visible across the bridge (fresh, not attributed
|
||||||
|
/// to the local island), for the people sheet.
|
||||||
|
struct BridgedParticipant: Identifiable, Equatable {
|
||||||
|
let pubkey: String
|
||||||
|
let nickname: String?
|
||||||
|
let lastSeen: Date
|
||||||
|
var id: String { pubkey }
|
||||||
|
/// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote
|
||||||
|
/// "anon"s stay distinguishable.
|
||||||
|
var displayName: String {
|
||||||
|
(nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = BridgeService()
|
||||||
|
|
||||||
|
/// The user toggle. While true this device publishes its own public mesh
|
||||||
|
/// messages to the rendezvous and subscribes to it when online.
|
||||||
|
@Published private(set) var isEnabled: Bool
|
||||||
|
/// Distinct remote rendezvous participants seen within the freshness
|
||||||
|
/// window. Approximate by design: local participants are subtracted by
|
||||||
|
/// matching their events' mesh message IDs against the local timeline,
|
||||||
|
/// which cannot attribute silent (presence-only) local peers.
|
||||||
|
@Published private(set) var bridgedPeerCount: Int = 0
|
||||||
|
/// The people behind the count, newest activity first.
|
||||||
|
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
|
||||||
|
/// The rendezvous cell currently in use, when the bridge is active.
|
||||||
|
@Published private(set) var activeCell: String?
|
||||||
|
/// Per-session compose flag: while true, outgoing messages stay on the
|
||||||
|
/// radio — no rendezvous copy is composed, so no gateway can carry them.
|
||||||
|
@Published var nearbyOnly: Bool = false
|
||||||
|
|
||||||
|
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||||
|
|
||||||
|
/// Publishes a signed event to the geo relays for a cell.
|
||||||
|
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
|
||||||
|
/// Opens the rendezvous subscription for (cell + neighbors); events are
|
||||||
|
/// fed back via `handleRendezvousEvent`.
|
||||||
|
var openSubscription: (@MainActor ([String]) -> Void)?
|
||||||
|
/// Closes the rendezvous subscription.
|
||||||
|
var closeSubscription: (@MainActor () -> Void)?
|
||||||
|
/// Whether any Nostr relay connection is currently working.
|
||||||
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
|
/// The local neighborhood cell from CoreLocation, if permitted.
|
||||||
|
var locationCell: (@MainActor () -> String?)?
|
||||||
|
/// Asks the location layer for a fresh one-shot fix. The bridge must
|
||||||
|
/// pump location itself: channel data otherwise only flows while some
|
||||||
|
/// other feature (channels sheet, location notes) happens to be active —
|
||||||
|
/// a field failure mode where the bridge silently never got a cell.
|
||||||
|
var requestLocationFix: (@MainActor () -> Void)?
|
||||||
|
/// A rendezvous cell advertised by a reachable mesh bridge peer's
|
||||||
|
/// announce — lets a mesh-only, location-less device still compose
|
||||||
|
/// correctly tagged events.
|
||||||
|
var meshAdvertisedCell: (@MainActor () -> String?)?
|
||||||
|
/// Sends an encoded `toBridge` carrier directed to a bridge peer.
|
||||||
|
var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)?
|
||||||
|
/// Reachable mesh peers advertising the `.bridge` capability.
|
||||||
|
var availableBridgePeers: (@MainActor () -> [PeerID])?
|
||||||
|
/// Broadcasts an encoded `fromBridge` carrier on the mesh.
|
||||||
|
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
||||||
|
/// Delivers a validated inbound bridge message to the mesh timeline.
|
||||||
|
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
|
||||||
|
/// True when the mesh timeline already holds this message ID (the radio
|
||||||
|
/// copy) — used to skip pointless downlink airtime.
|
||||||
|
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
|
||||||
|
/// Derives the unlinkable per-cell rendezvous identity.
|
||||||
|
var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)?
|
||||||
|
/// Local nickname for the `n` tag.
|
||||||
|
var myNickname: (@MainActor () -> String)?
|
||||||
|
/// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce).
|
||||||
|
var onEnabledChanged: (@MainActor (Bool) -> Void)?
|
||||||
|
/// Fired when the active rendezvous cell changes (including to nil) so
|
||||||
|
/// the announce advertisement stays current.
|
||||||
|
var onActiveCellChanged: (@MainActor (String?) -> Void)?
|
||||||
|
/// Schedules a closure after a delay; nil arms a real `Task`. Injected so
|
||||||
|
/// timers are deterministic in tests.
|
||||||
|
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
/// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts.
|
||||||
|
private var meshBroadcastEventIDs: BoundedIDSet
|
||||||
|
/// Loop rule 2: event IDs this device published (own or deposited).
|
||||||
|
private var publishedEventIDs: BoundedIDSet
|
||||||
|
/// Loop rule 3: event IDs this device already rebroadcast.
|
||||||
|
private var rebroadcastEventIDs: BoundedIDSet
|
||||||
|
/// Timeline message IDs already injected (either arrival path).
|
||||||
|
private var injectedMessageIDs: BoundedIDSet
|
||||||
|
|
||||||
|
/// Cells the rendezvous subscription covers (own + neighbor ring).
|
||||||
|
private(set) var subscribedCells: Set<String> = []
|
||||||
|
private(set) var queuedUplinks: [QueuedUplink] = []
|
||||||
|
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
||||||
|
private var downlinkSendTimes: [Date] = []
|
||||||
|
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
|
||||||
|
private var downlinkDrainScheduled = false
|
||||||
|
private var presenceTimerArmed = false
|
||||||
|
private var lastPresenceAt = Date.distantPast
|
||||||
|
|
||||||
|
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
|
||||||
|
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let now: () -> Date
|
||||||
|
private static let enabledKey = "bridge.userEnabled"
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.now = now
|
||||||
|
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
|
||||||
|
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle & lifecycle
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
guard enabled != isEnabled else { return }
|
||||||
|
isEnabled = enabled
|
||||||
|
defaults.set(enabled, forKey: Self.enabledKey)
|
||||||
|
if !enabled {
|
||||||
|
queuedUplinks.removeAll()
|
||||||
|
pendingDownlinks.removeAll()
|
||||||
|
uplinkDepositTimes.removeAll()
|
||||||
|
participants.removeAll()
|
||||||
|
bridgedPeerCount = 0
|
||||||
|
bridgedParticipants = []
|
||||||
|
}
|
||||||
|
SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||||
|
refreshRendezvous()
|
||||||
|
onEnabledChanged?(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes the active cell and (re)opens or closes the subscription.
|
||||||
|
/// Call on toggle changes, location updates, and relay connectivity
|
||||||
|
/// changes; idempotent.
|
||||||
|
func refreshRendezvous() {
|
||||||
|
let cell = isEnabled ? currentCell() : nil
|
||||||
|
// No cell yet: ask for a fix — the availableChannels change re-enters
|
||||||
|
// here once it lands.
|
||||||
|
if isEnabled, cell == nil {
|
||||||
|
requestLocationFix?()
|
||||||
|
}
|
||||||
|
guard cell != activeCell else {
|
||||||
|
// The maintenance timer must run even cell-less: it is what
|
||||||
|
// retries the location fix (launch races the permission
|
||||||
|
// callback, so the first request can silently no-op).
|
||||||
|
if isEnabled { armPresenceTimerIfNeeded() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if activeCell != nil {
|
||||||
|
closeSubscription?()
|
||||||
|
subscribedCells = []
|
||||||
|
}
|
||||||
|
activeCell = cell
|
||||||
|
onActiveCellChanged?(cell)
|
||||||
|
guard let cell else {
|
||||||
|
if isEnabled { armPresenceTimerIfNeeded() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Own cell + neighbors: islands straddling a cell edge still meet.
|
||||||
|
// Publishes go to the own cell only; symmetric because both sides
|
||||||
|
// subscribe to each other's cell via the neighbor ring.
|
||||||
|
let cells = [cell] + Geohash.neighbors(of: cell)
|
||||||
|
subscribedCells = Set(cells)
|
||||||
|
openSubscription?(cells)
|
||||||
|
SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session)
|
||||||
|
publishPresence()
|
||||||
|
armPresenceTimerIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rendezvous cell: our own location when we have it, else the cell
|
||||||
|
/// a reachable bridge gateway advertises in its announce.
|
||||||
|
private func currentCell() -> String? {
|
||||||
|
if let own = locationCell?(), !own.isEmpty {
|
||||||
|
return String(own.prefix(Limits.cellPrecision))
|
||||||
|
}
|
||||||
|
if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) {
|
||||||
|
return String(advertised.prefix(Limits.cellPrecision))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// One switch does the right thing: while bridging, a device with
|
||||||
|
// internet automatically serves its island (accepts deposits, carries
|
||||||
|
// remote messages onto the radio). The marginal cost over bridging
|
||||||
|
// yourself is small — the relay connections and subscription already
|
||||||
|
// exist for you — and a separate "serve others" lever proved to be a
|
||||||
|
// silent trap for mesh-only neighbors.
|
||||||
|
|
||||||
|
// MARK: - Outgoing (sender role)
|
||||||
|
|
||||||
|
/// Composes and ships the bridged copy of an outgoing public mesh
|
||||||
|
/// message. Call after the radio send; no-op when the bridge is off,
|
||||||
|
/// no cell is known, or the message was flagged nearby-only upstream.
|
||||||
|
func bridgeOutgoing(content: String, messageID: String) {
|
||||||
|
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
|
||||||
|
guard content.utf8.count <= Limits.maxContentBytes else { return }
|
||||||
|
guard let identity = try? deriveIdentity?(cell),
|
||||||
|
let event = try? NostrProtocol.createBridgeMeshEvent(
|
||||||
|
content: content,
|
||||||
|
cell: cell,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: myNickname?(),
|
||||||
|
meshMessageID: messageID
|
||||||
|
) else {
|
||||||
|
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishedEventIDs.insert(event.id)
|
||||||
|
injectedMessageIDs.insert(messageID) // our own timeline already has it
|
||||||
|
if relaysConnected?() ?? false {
|
||||||
|
publishToRelays?(event, cell)
|
||||||
|
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
|
||||||
|
let payload = carrier.encode(),
|
||||||
|
let gateway = availableBridgePeers?().first {
|
||||||
|
if sendToBridgePeer?(payload, gateway) ?? false {
|
||||||
|
SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes a presence heartbeat so silent participants still register
|
||||||
|
/// across the bridge. Throttled: several triggers (enable, cell change,
|
||||||
|
/// relay reconnect) can coincide, and same-second heartbeats are
|
||||||
|
/// byte-identical events anyway.
|
||||||
|
func publishPresence() {
|
||||||
|
guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return }
|
||||||
|
guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return }
|
||||||
|
lastPresenceAt = now()
|
||||||
|
guard let identity = try? deriveIdentity?(cell),
|
||||||
|
let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return }
|
||||||
|
publishedEventIDs.insert(event.id)
|
||||||
|
publishToRelays?(event, cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maintenance heartbeat while bridging: presence, participant pruning,
|
||||||
|
/// and a location retry. Runs with or without a cell — the cell-less
|
||||||
|
/// case is exactly when the location retry matters.
|
||||||
|
private func armPresenceTimerIfNeeded() {
|
||||||
|
guard isEnabled, !presenceTimerArmed else { return }
|
||||||
|
presenceTimerArmed = true
|
||||||
|
let fire: @MainActor () -> Void = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.presenceTimerArmed = false
|
||||||
|
self.publishPresence()
|
||||||
|
self.pruneParticipants()
|
||||||
|
// Location refresh: migrates cells on a moving device and
|
||||||
|
// recovers a launch that raced the permission callback.
|
||||||
|
if self.activeCell == nil {
|
||||||
|
self.refreshRendezvous()
|
||||||
|
} else {
|
||||||
|
self.requestLocationFix?()
|
||||||
|
}
|
||||||
|
self.armPresenceTimerIfNeeded()
|
||||||
|
}
|
||||||
|
if let scheduleTimer {
|
||||||
|
scheduleTimer(Limits.presenceIntervalSeconds, fire)
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000))
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription ingress (internet role)
|
||||||
|
|
||||||
|
/// Entry point for every event the rendezvous subscription delivers.
|
||||||
|
/// Handles presence accounting, timeline injection, and — when acting as
|
||||||
|
/// the island's gateway — downlink rebroadcast.
|
||||||
|
func handleRendezvousEvent(_ event: NostrEvent) {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
// The subscription spans our cell + neighbors; trust only the
|
||||||
|
// event's own signed `r` tag, and only within that ring.
|
||||||
|
guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1],
|
||||||
|
subscribedCells.contains(cell) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let kind = classify(event, cell: cell) else { return }
|
||||||
|
// Events we published come back from our own subscription; they are
|
||||||
|
// presence-neutral (we never count ourselves) and never re-injected
|
||||||
|
// or rebroadcast. Two layers: the published-ID cache (this session)
|
||||||
|
// and pubkey self-recognition — the rendezvous identity is derived
|
||||||
|
// deterministically, so even after a relaunch wipes the cache our
|
||||||
|
// own relay-backfilled events are recognized (field bug: own
|
||||||
|
// pre-restart messages re-rendered as bridged).
|
||||||
|
guard !publishedEventIDs.contains(event.id) else { return }
|
||||||
|
if isOwnRendezvousEvent(event, cell: cell) {
|
||||||
|
publishedEventIDs.insert(event.id) // never downlink it either
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard event.isValidSignature() else { return }
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case .presence:
|
||||||
|
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
|
||||||
|
case .message(let message):
|
||||||
|
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
|
||||||
|
if isLocalRadioCopy {
|
||||||
|
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
|
||||||
|
}
|
||||||
|
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
|
||||||
|
inject(message)
|
||||||
|
// Serving duty: carry remote islands' messages onto the radio for
|
||||||
|
// mesh-only peers. Local-origin events are skipped — the island
|
||||||
|
// already heard them (loop rule 3). The drain is jitter-delayed:
|
||||||
|
// with every online bridger serving, the holdoff lets gateways
|
||||||
|
// hear each other's broadcasts and skip duplicates.
|
||||||
|
if !isLocalRadioCopy,
|
||||||
|
!meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!rebroadcastEventIDs.contains(event.id),
|
||||||
|
!pendingDownlinks.contains(where: { $0.event.id == event.id }) {
|
||||||
|
pendingDownlinks.append((event, cell))
|
||||||
|
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||||
|
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||||
|
}
|
||||||
|
scheduleDownlinkDrainIfNeeded(jitter: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh carrier ingress (both roles)
|
||||||
|
|
||||||
|
/// Entry point for received `nostrCarrier` packets with bridge
|
||||||
|
/// directions. `directedToUs` is true for `toBridge` deposits addressed
|
||||||
|
/// to this device; false for `fromBridge` broadcasts.
|
||||||
|
func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) {
|
||||||
|
switch carrier.direction {
|
||||||
|
case .toBridge:
|
||||||
|
guard directedToUs else { return }
|
||||||
|
handleUplinkDeposit(carrier, from: peerID)
|
||||||
|
case .fromBridge:
|
||||||
|
guard !directedToUs else { return }
|
||||||
|
handleDownlinkBroadcast(carrier)
|
||||||
|
case .toGateway, .fromGateway:
|
||||||
|
return // GatewayService territory; routed there by the caller.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uplink (gateway role: mesh peer -> internet)
|
||||||
|
|
||||||
|
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
// Cheap structural gates before any crypto, mirroring GatewayService.
|
||||||
|
guard let event = structurallyValidEvent(from: carrier) else {
|
||||||
|
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!publishedEventIDs.contains(event.id),
|
||||||
|
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard allowUplinkDeposit(from: depositor) else {
|
||||||
|
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard event.isValidSignature() else {
|
||||||
|
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if relaysConnected?() ?? false {
|
||||||
|
publish(event, cell: carrier.geohash)
|
||||||
|
} else {
|
||||||
|
enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event))
|
||||||
|
}
|
||||||
|
// No local injection: the depositor's radio broadcast already carried
|
||||||
|
// the message to this island, including us.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish everything queued while relays were unreachable.
|
||||||
|
func flushQueuedUplinks() {
|
||||||
|
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
|
||||||
|
let queued = queuedUplinks
|
||||||
|
queuedUplinks.removeAll()
|
||||||
|
for item in queued where !publishedEventIDs.contains(item.event.id) {
|
||||||
|
publish(item.event, cell: item.cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publish(_ event: NostrEvent, cell: String) {
|
||||||
|
publishedEventIDs.insert(event.id)
|
||||||
|
publishToRelays?(event, cell)
|
||||||
|
SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||||
|
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||||
|
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false }
|
||||||
|
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||||
|
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
|
||||||
|
}
|
||||||
|
queuedUplinks.append(item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
|
||||||
|
let cutoff = now().addingTimeInterval(-60)
|
||||||
|
var times = uplinkDepositTimes[depositor, default: []]
|
||||||
|
times.removeAll { $0 < cutoff }
|
||||||
|
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
|
||||||
|
uplinkDepositTimes[depositor] = times
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
times.append(now())
|
||||||
|
uplinkDepositTimes[depositor] = times
|
||||||
|
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
|
||||||
|
uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downlink (gateway role: internet -> mesh)
|
||||||
|
|
||||||
|
private func drainPendingDownlinks() {
|
||||||
|
let cutoff = now().addingTimeInterval(-60)
|
||||||
|
downlinkSendTimes.removeAll { $0 < cutoff }
|
||||||
|
while !pendingDownlinks.isEmpty,
|
||||||
|
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
|
||||||
|
let (event, cell) = pendingDownlinks.removeFirst()
|
||||||
|
guard isFresh(event) else { continue }
|
||||||
|
// Suppression recheck at send time: another gateway may have
|
||||||
|
// broadcast this event during our jitter holdoff.
|
||||||
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!rebroadcastEventIDs.contains(event.id) else { continue }
|
||||||
|
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
|
||||||
|
let payload = carrier.encode() else { continue }
|
||||||
|
broadcastToMesh?(payload)
|
||||||
|
SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session)
|
||||||
|
// Mark-after-send (loop rule 3): a queue-overflow drop stays
|
||||||
|
// retryable on relay redelivery.
|
||||||
|
rebroadcastEventIDs.insert(event.id)
|
||||||
|
downlinkSendTimes.append(now())
|
||||||
|
}
|
||||||
|
scheduleDownlinkDrainIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) {
|
||||||
|
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
|
||||||
|
let delay: TimeInterval
|
||||||
|
if jitter {
|
||||||
|
// Multi-gateway suppression window: enough spread for another
|
||||||
|
// gateway's broadcast to land and mark the event mesh-carried.
|
||||||
|
delay = Double.random(in: 0.2...1.5)
|
||||||
|
} else {
|
||||||
|
let oldest = downlinkSendTimes.min() ?? now()
|
||||||
|
delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||||
|
}
|
||||||
|
downlinkDrainScheduled = true
|
||||||
|
let fire: @MainActor () -> Void = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.downlinkDrainScheduled = false
|
||||||
|
self.drainPendingDownlinks()
|
||||||
|
}
|
||||||
|
if let scheduleTimer {
|
||||||
|
scheduleTimer(delay, fire)
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downlink (receiver role: carried event arrives over radio)
|
||||||
|
|
||||||
|
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
|
||||||
|
// Reception is deliberately NOT gated on the toggle: it is passive
|
||||||
|
// radio, and two phones side by side should not disagree about what
|
||||||
|
// the channel said. Publishing/subscribing remain opt-in.
|
||||||
|
guard let event = structurallyValidEvent(from: carrier),
|
||||||
|
!publishedEventIDs.contains(event.id),
|
||||||
|
!isOwnRendezvousEvent(event, cell: carrier.geohash),
|
||||||
|
event.isValidSignature() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mark after verification (a forged copy must not poison the cache),
|
||||||
|
// and use the marking as multi-path dedup.
|
||||||
|
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
||||||
|
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
|
||||||
|
inject(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Injection & participants
|
||||||
|
|
||||||
|
private func inject(_ message: InboundBridgeMessage) {
|
||||||
|
guard injectedMessageIDs.insert(message.messageID) else { return }
|
||||||
|
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
|
||||||
|
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
|
||||||
|
injectInbound?(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
|
||||||
|
let previous = participants[pubkey]
|
||||||
|
// Local attribution is sticky: one radio-confirmed message marks the
|
||||||
|
// pubkey as an islander for as long as they stay fresh. Presence
|
||||||
|
// events carry no nickname, so a known name is never forgotten.
|
||||||
|
participants[pubkey] = (
|
||||||
|
lastSeen: now(),
|
||||||
|
isLocal: (previous?.isLocal ?? false) || isLocal,
|
||||||
|
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
|
||||||
|
)
|
||||||
|
recomputeBridgedCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruneParticipants() {
|
||||||
|
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
||||||
|
participants = participants.filter { $0.value.lastSeen >= cutoff }
|
||||||
|
recomputeBridgedCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recomputeBridgedCount() {
|
||||||
|
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
|
||||||
|
let visible = participants
|
||||||
|
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
|
||||||
|
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
|
||||||
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
|
if visible.count != bridgedPeerCount {
|
||||||
|
bridgedPeerCount = visible.count
|
||||||
|
}
|
||||||
|
if visible != bridgedParticipants {
|
||||||
|
bridgedParticipants = visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
private enum RendezvousKind {
|
||||||
|
case message(InboundBridgeMessage)
|
||||||
|
case presence
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies a structurally acceptable rendezvous event; nil rejects.
|
||||||
|
private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? {
|
||||||
|
guard isFresh(event),
|
||||||
|
event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }),
|
||||||
|
GatewayService.isValidGeohash(cell) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch event.kind {
|
||||||
|
case NostrProtocol.EventKind.geohashPresence.rawValue:
|
||||||
|
return .presence
|
||||||
|
case NostrProtocol.EventKind.ephemeralEvent.rawValue:
|
||||||
|
let content = event.content
|
||||||
|
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
|
||||||
|
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
|
||||||
|
let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1]
|
||||||
|
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
|
||||||
|
return .message(InboundBridgeMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))",
|
||||||
|
senderPubkey: event.pubkey,
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse + size + cell + kind + `r` tag + freshness, with NO signature
|
||||||
|
/// verification — callers dedup/rate-limit first, Schnorr-verify last.
|
||||||
|
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||||
|
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
|
||||||
|
GatewayService.isValidGeohash(carrier.geohash),
|
||||||
|
let event = carrier.event(),
|
||||||
|
classify(event, cell: carrier.geohash) != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFresh(_ event: NostrEvent) -> Bool {
|
||||||
|
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the event was signed by this device's own derived
|
||||||
|
/// rendezvous identity for the cell. Survives relaunches (unlike the
|
||||||
|
/// published-ID cache) because the derivation is deterministic; the
|
||||||
|
/// underlying identity cache makes this cheap.
|
||||||
|
private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool {
|
||||||
|
guard let identity = try? deriveIdentity?(cell) else { return false }
|
||||||
|
return identity.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||