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 |
@@ -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
|
||||||
@@ -62,6 +66,11 @@ 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
|
/// Builds the mesh topology map model from the transport's gossiped
|
||||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
/// 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.
|
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
private let manager: LocationChannelManager
|
private let manager: LocationChannelManager
|
||||||
private let network: NetworkActivationService
|
private let network: NetworkActivationService
|
||||||
private let gateway: GatewayService
|
private let gateway: GatewayService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
manager: LocationChannelManager? = nil,
|
manager: LocationChannelManager? = nil,
|
||||||
@@ -102,10 +100,6 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.setUserTorEnabled(enabled)
|
network.setUserTorEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGatewayEnabled(_ enabled: Bool) {
|
|
||||||
gateway.setEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshMeshChannelsIfNeeded() {
|
func refreshMeshChannelsIfNeeded() {
|
||||||
guard case .mesh = selectedChannel,
|
guard case .mesh = selectedChannel,
|
||||||
permissionState == .authorized,
|
permissionState == .authorized,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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?
|
||||||
@@ -56,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
|
||||||
@@ -110,7 +105,6 @@ final class VerificationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return FingerprintPresentationState(
|
return FingerprintPresentationState(
|
||||||
statusPeerID: statusPeerID,
|
|
||||||
peerNickname: peerNickname,
|
peerNickname: peerNickname,
|
||||||
encryptionStatus: encryptionStatus,
|
encryptionStatus: encryptionStatus,
|
||||||
theirFingerprint: theirFingerprint,
|
theirFingerprint: theirFingerprint,
|
||||||
|
|||||||
|
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 |
@@ -8,9 +8,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
#if DEBUG
|
|
||||||
import BitLogger
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
@@ -43,14 +40,10 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(runtime.locationChannelsModel)
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
.environmentObject(runtime.peerListModel)
|
.environmentObject(runtime.peerListModel)
|
||||||
.environmentObject(runtime.appChromeModel)
|
.environmentObject(runtime.appChromeModel)
|
||||||
|
.environmentObject(runtime.boardAlertsModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
runtime.start()
|
||||||
#if DEBUG
|
|
||||||
// Arm the opt-in UDP log sink from persisted config (no-op
|
|
||||||
// until a collector host is set in the App Info sheet).
|
|
||||||
LogNetworkSink.shared.reloadConfiguration()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
runtime.handleOpenURL(url)
|
runtime.handleOpenURL(url)
|
||||||
@@ -111,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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -193,9 +189,6 @@ struct IdentityCache: Codable {
|
|||||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||||
// entries verified before this field exists sort as oldest)
|
// entries verified before this field exists sort as oldest)
|
||||||
var verifiedAt: [String: Date]? = nil
|
var verifiedAt: [String: Date]? = nil
|
||||||
|
|
||||||
// Schema version for future migrations
|
|
||||||
var version: Int = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -139,7 +136,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||||
func isVouched(fingerprint: String) -> Bool
|
func isVouched(fingerprint: String) -> Bool
|
||||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
|
|
||||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
@@ -322,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 {
|
||||||
@@ -345,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
|
||||||
}
|
}
|
||||||
@@ -511,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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case unfavorite = "unfav"
|
case unfavorite = "unfav"
|
||||||
case ping
|
case ping
|
||||||
case trace
|
case trace
|
||||||
|
case drop
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable {
|
|||||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||||
case .pay:
|
case .pay:
|
||||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
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
|
||||||
}
|
}
|
||||||
@@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||||
case .ping: String(localized: "content.commands.ping")
|
case .ping: String(localized: "content.commands.ping")
|
||||||
case .trace: String(localized: "content.commands.trace")
|
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] {
|
||||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||||
// processor still allows it behind an explicit "public" confirm).
|
// processor still allows it behind an explicit "public" confirm).
|
||||||
@@ -74,11 +78,11 @@ enum CommandInfo: String, Identifiable {
|
|||||||
if !isGeoPublic {
|
if !isGeoPublic {
|
||||||
commands.append(.pay)
|
commands.append(.pay)
|
||||||
}
|
}
|
||||||
// The processor rejects favorites, groups and mesh diagnostics in
|
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
// geohash contexts, so only suggest them where they work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -255,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(),
|
||||||
@@ -276,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(
|
||||||
@@ -523,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
|
||||||
@@ -683,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),
|
||||||
|
|||||||
@@ -74,12 +74,11 @@ 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 bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
|
||||||
// Private groups
|
|
||||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
|
// 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
|
||||||
@@ -91,10 +90,9 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
|
||||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
|
||||||
case .groupInvite: return "groupInvite"
|
case .groupInvite: return "groupInvite"
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
|
case .voiceFrame: return "voiceFrame"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
case .vouch: return "vouch"
|
||||||
@@ -132,6 +130,9 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
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?)
|
||||||
@@ -155,6 +156,10 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ struct NostrCarrierPacket: Equatable {
|
|||||||
enum Direction: UInt8 {
|
enum Direction: UInt8 {
|
||||||
case toGateway = 0x01
|
case toGateway = 0x01
|
||||||
case fromGateway = 0x02
|
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 direction: Direction
|
||||||
|
|||||||
@@ -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,9 +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 = {
|
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
|
||||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
|
||||||
return caps
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 +61,6 @@ 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
|
let isBroadcast: Bool
|
||||||
@@ -150,7 +149,6 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
fragmentsByKey[header.key] = [:]
|
||||||
metadataByKey[header.key] = Metadata(
|
metadataByKey[header.key] = Metadata(
|
||||||
type: header.originalType,
|
|
||||||
total: header.total,
|
total: header.total,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
isBroadcast: header.isBroadcastFragment,
|
isBroadcast: header.isBroadcastFragment,
|
||||||
|
|||||||
@@ -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, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
// 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 {
|
||||||
@@ -117,6 +119,15 @@ struct BLEPeerRegistry {
|
|||||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.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) }
|
||||||
@@ -141,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
|
||||||
@@ -168,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(
|
||||||
@@ -185,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,16 +51,16 @@ struct BLEReceivePipeline {
|
|||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
// remote handover toward a relayed announce rides this same
|
// remote handover toward a relayed announce rides this same
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
// Ping/pong diagnostics ride it too: probes need the same
|
||||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
|
||||||
// Ping/pong diagnostics also ride it: probes need the same
|
|
||||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
// 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.type == MessageType.courierEnvelope.rawValue
|
||||||
|| packet.type == MessageType.nostrCarrier.rawValue
|
|
||||||
|| packet.type == MessageType.ping.rawValue
|
|| packet.type == MessageType.ping.rawValue
|
||||||
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil,
|
|| 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,
|
||||||
@@ -70,6 +70,7 @@ struct BLEReceivePipeline {
|
|||||||
// announce-class TTL headroom so alerts travel the extra hop.
|
// announce-class TTL headroom so alerts travel the extra hop.
|
||||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||||
&& BoardWire.urgentFlag(in: packet.payload),
|
&& 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,8 @@ import Foundation
|
|||||||
/// Decides whether an outbound directed packet should carry a v2 source
|
/// Decides whether an outbound directed packet should carry a v2 source
|
||||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
||||||
enum BLESourceRouteOriginationPolicy {
|
enum BLESourceRouteOriginationPolicy {
|
||||||
/// Why a packet kept flood/direct-write behavior instead of routing.
|
/// Returns the intermediate-hop route to attach, or nil to keep the
|
||||||
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
|
/// current flood/direct-write behavior unchanged.
|
||||||
enum FloodReason: String {
|
|
||||||
case relayedNotOriginator = "not originator"
|
|
||||||
case broadcast = "broadcast recipient"
|
|
||||||
case noTTLHeadroom = "link-local ttl"
|
|
||||||
case recipientDirect = "recipient direct"
|
|
||||||
case routeSuppressed = "route suppressed→flood"
|
|
||||||
case noPath = "no v2 path"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The routing decision for an originated packet.
|
|
||||||
enum Decision: Equatable {
|
|
||||||
/// Keep flood/direct-write behavior unchanged; carries the reason.
|
|
||||||
case flood(FloodReason)
|
|
||||||
/// Originate a v2 source route over these intermediate hops.
|
|
||||||
case route([Data])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether to originate a v2 source route (with its hops) or keep
|
|
||||||
/// flood/direct-write, with the reason for the latter.
|
|
||||||
///
|
///
|
||||||
/// Routes are only originated when every gate passes:
|
/// Routes are only originated when every gate passes:
|
||||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||||
@@ -38,22 +19,22 @@ enum BLESourceRouteOriginationPolicy {
|
|||||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||||
/// routed send, and
|
/// routed send, and
|
||||||
/// - the topology yields a complete path.
|
/// - the topology yields a complete path.
|
||||||
static func decide(
|
static func route(
|
||||||
for packet: BitchatPacket,
|
for packet: BitchatPacket,
|
||||||
to recipient: PeerID,
|
to recipient: PeerID,
|
||||||
localPeerIDData: Data,
|
localPeerIDData: Data,
|
||||||
isRecipientConnected: (PeerID) -> Bool,
|
isRecipientConnected: (PeerID) -> Bool,
|
||||||
shouldAttemptRoute: (PeerID) -> Bool,
|
shouldAttemptRoute: (PeerID) -> Bool,
|
||||||
computeRoute: (PeerID) -> [Data]?
|
computeRoute: (PeerID) -> [Data]?
|
||||||
) -> Decision {
|
) -> [Data]? {
|
||||||
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
|
guard packet.senderID == localPeerIDData else { return nil }
|
||||||
guard let recipientData = packet.recipientID,
|
guard let recipientData = packet.recipientID,
|
||||||
recipientData.count == 8,
|
recipientData.count == 8,
|
||||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
|
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
||||||
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
|
guard packet.ttl > 1 else { return nil }
|
||||||
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
|
guard !isRecipientConnected(recipient) else { return nil }
|
||||||
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
|
guard shouldAttemptRoute(recipient) else { return nil }
|
||||||
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
|
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
||||||
return .route(route)
|
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) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,18 +19,27 @@ final class BoardManager: ObservableObject {
|
|||||||
@Published private(set) var posts: [BoardPostPacket] = []
|
@Published private(set) var posts: [BoardPostPacket] = []
|
||||||
|
|
||||||
private let transport: Transport
|
private let transport: Transport
|
||||||
private let store: BoardStore
|
/// Publishes a bridged kind-1 note (expiring with the board post via
|
||||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
|
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
||||||
|
/// was skipped.
|
||||||
|
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ 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?
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
store: BoardStore = .shared,
|
store: BoardStore = .shared,
|
||||||
publishToNostr: ((String, String, String) -> Void)? = nil
|
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
|
||||||
|
deleteFromNostr: ((String, String) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.store = store
|
|
||||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||||
|
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
||||||
cancellable = store.$postsSnapshot
|
cancellable = store.$postsSnapshot
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] snapshot in
|
.sink { [weak self] snapshot in
|
||||||
@@ -115,10 +124,10 @@ final class BoardManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||||
|
|
||||||
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
|
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
||||||
// location notes so online users see them. No inbound merge yet.
|
// online users see them. Remember the event id for merged deletes.
|
||||||
if !geohash.isEmpty {
|
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
|
||||||
publishToNostr(trimmed, geohash, cleanNickname)
|
bridgedEventIDs[postID] = eventID
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -140,14 +149,20 @@ final class BoardManager: ObservableObject {
|
|||||||
signature: signature
|
signature: signature
|
||||||
)
|
)
|
||||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||||
|
|
||||||
|
// Merged delete: also retract the bridged Nostr copy when we still
|
||||||
|
// know its event id.
|
||||||
|
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
|
||||||
|
deleteFromNostr(eventID, post.geohash)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
|
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)
|
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||||
guard !relays.isEmpty else {
|
guard !relays.isEmpty else {
|
||||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||||
@@ -155,11 +170,27 @@ final class BoardManager: ObservableObject {
|
|||||||
content: content,
|
content: content,
|
||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: nickname
|
nickname: nickname,
|
||||||
|
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
|
||||||
|
urgent: urgent
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
|
return event.id
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
|
||||||
|
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else { return }
|
||||||
|
do {
|
||||||
|
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||||
|
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
|
||||||
|
NostrRelayManager.shared.sendEvent(deletion, to: relays)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ final class BoardStore {
|
|||||||
/// Live posts, published on the main thread for the board UI.
|
/// Live posts, published on the main thread for the board UI.
|
||||||
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
||||||
|
|
||||||
|
/// Fires on the main thread for each post newly accepted from the wire
|
||||||
|
/// (radio, sync, or local echo) — not for disk restores. Drives the
|
||||||
|
/// local new-pin chat alerts; duplicates never fire twice because the
|
||||||
|
/// store rejects them.
|
||||||
|
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
|
||||||
|
|
||||||
|
/// Fires on the main thread after a panic wipe so derived state (pending
|
||||||
|
/// alerts, unseen badges) is dropped along with the posts themselves.
|
||||||
|
let didWipe = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
private var posts: [StoredPost] = []
|
private var posts: [StoredPost] = []
|
||||||
private var tombstones: [StoredTombstone] = []
|
private var tombstones: [StoredTombstone] = []
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
||||||
@@ -104,6 +114,11 @@ final class BoardStore {
|
|||||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||||
if result == .accepted {
|
if result == .accepted {
|
||||||
persistLocked()
|
persistLocked()
|
||||||
|
if case .post(let post) = wire {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.postArrivals.send(post)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -131,14 +146,6 @@ final class BoardStore {
|
|||||||
|
|
||||||
// MARK: - Maintenance
|
// MARK: - Maintenance
|
||||||
|
|
||||||
func pruneExpired() {
|
|
||||||
let nowMs = currentMs()
|
|
||||||
queue.sync {
|
|
||||||
pruneExpiredLocked(nowMs: nowMs)
|
|
||||||
persistLocked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panic wipe: drop all board data from memory and disk.
|
/// Panic wipe: drop all board data from memory and disk.
|
||||||
func wipe() {
|
func wipe() {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
@@ -149,6 +156,9 @@ final class BoardStore {
|
|||||||
}
|
}
|
||||||
publishSnapshotLocked()
|
publishSnapshotLocked()
|
||||||
}
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.didWipe.send()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals (call only on `queue`)
|
// MARK: - Internals (call only on `queue`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,8 @@ final class CommandProcessor {
|
|||||||
return handleTrace(args)
|
return handleTrace(args)
|
||||||
case "/pay":
|
case "/pay":
|
||||||
return handlePay(args)
|
return handlePay(args)
|
||||||
|
case "/drop":
|
||||||
|
return handleDrop(args)
|
||||||
case "/help":
|
case "/help":
|
||||||
return .success(message: Self.helpText)
|
return .success(message: Self.helpText)
|
||||||
default:
|
default:
|
||||||
@@ -171,9 +173,36 @@ final class CommandProcessor {
|
|||||||
/ping @name — measure round-trip time (mesh only)
|
/ping @name — measure round-trip time (mesh only)
|
||||||
/trace @name — estimated mesh path (mesh only)
|
/trace @name — estimated mesh path (mesh only)
|
||||||
/pay <token> — send a cashu ecash token in this chat
|
/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 {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ 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.
|
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||||
let prekeyID: UInt32?
|
let prekeyID: UInt32?
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ final class CourierStore {
|
|||||||
copies: UInt8,
|
copies: UInt8,
|
||||||
sprayedTo: Set<Data> = [],
|
sprayedTo: Set<Data> = [],
|
||||||
lastRemoteHandoverAt: Date? = nil,
|
lastRemoteHandoverAt: Date? = nil,
|
||||||
|
lastBridgePublishAt: Date? = nil,
|
||||||
prekeyID: UInt32? = nil
|
prekeyID: UInt32? = nil
|
||||||
) {
|
) {
|
||||||
self.recipientTag = recipientTag
|
self.recipientTag = recipientTag
|
||||||
@@ -70,6 +73,7 @@ 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
|
self.prekeyID = prekeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +90,7 @@ 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)
|
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,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
|
||||||
@@ -272,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,11 @@ import Foundation
|
|||||||
/// original broadcast packet is the TTL relay's job, not ours.
|
/// original broadcast packet is the TTL relay's job, not ours.
|
||||||
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
||||||
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
||||||
/// repeat deposits and relay echoes are absorbed.
|
/// repeat deposits and relay echoes are absorbed. An event this gateway
|
||||||
|
/// itself uplinked (`publishedEventIDs`) is additionally never
|
||||||
|
/// downlink-rebroadcast: it originated on this mesh, so echoing it back
|
||||||
|
/// when our own relay subscription redelivers it would double BLE airtime
|
||||||
|
/// (the device-confirmed self-echo bug).
|
||||||
/// 3. Uplink is only attempted for locally composed events at the send site
|
/// 3. Uplink is only attempted for locally composed events at the send site
|
||||||
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
||||||
/// carrier never re-enter the uplink path. This is a call-site convention;
|
/// carrier never re-enter the uplink path. This is a call-site convention;
|
||||||
@@ -78,7 +82,6 @@ final class GatewayService: ObservableObject {
|
|||||||
let depositor: PeerID
|
let depositor: PeerID
|
||||||
let geohash: String
|
let geohash: String
|
||||||
let event: NostrEvent
|
let event: NostrEvent
|
||||||
let queuedAt: Date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static let shared = GatewayService()
|
static let shared = GatewayService()
|
||||||
@@ -152,7 +155,7 @@ final class GatewayService: ObservableObject {
|
|||||||
pendingDownlinks.removeAll()
|
pendingDownlinks.removeAll()
|
||||||
uplinkDepositTimes.removeAll()
|
uplinkDepositTimes.removeAll()
|
||||||
}
|
}
|
||||||
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
|
SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session)
|
||||||
onEnabledChanged?(enabled)
|
onEnabledChanged?(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +166,7 @@ final class GatewayService: ObservableObject {
|
|||||||
/// for broadcasts (downlink rebroadcasts from a gateway).
|
/// for broadcasts (downlink rebroadcasts from a gateway).
|
||||||
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
||||||
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
||||||
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway)
|
SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch carrier.direction {
|
switch carrier.direction {
|
||||||
@@ -175,6 +178,10 @@ final class GatewayService: ObservableObject {
|
|||||||
// Downlink rides broadcast only; a directed fromGateway is malformed.
|
// Downlink rides broadcast only; a directed fromGateway is malformed.
|
||||||
guard !directedToUs else { return }
|
guard !directedToUs else { return }
|
||||||
handleDownlinkBroadcast(carrier)
|
handleDownlinkBroadcast(carrier)
|
||||||
|
case .toBridge, .fromBridge:
|
||||||
|
// Mesh-bridge carriers are BridgeService territory; the ingress
|
||||||
|
// router dispatches them there.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +193,7 @@ final class GatewayService: ObservableObject {
|
|||||||
// age) — no crypto — so junk and stale replays are dropped before we
|
// age) — no crypto — so junk and stale replays are dropped before we
|
||||||
// ever pay for a MainActor Schnorr verify.
|
// ever pay for a MainActor Schnorr verify.
|
||||||
guard let event = structurallyValidEvent(from: carrier) else {
|
guard let event = structurallyValidEvent(from: carrier) else {
|
||||||
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway)
|
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||||
@@ -197,19 +204,18 @@ final class GatewayService: ObservableObject {
|
|||||||
guard !meshBroadcastEventIDs.contains(event.id),
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
!publishedEventIDs.contains(event.id),
|
!publishedEventIDs.contains(event.id),
|
||||||
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Consume the per-depositor rate token BEFORE the expensive verify so
|
// Consume the per-depositor rate token BEFORE the expensive verify so
|
||||||
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
||||||
// not by main-actor Schnorr verifications.
|
// not by main-actor Schnorr verifications.
|
||||||
guard allowUplinkDeposit(from: depositor) else {
|
guard allowUplinkDeposit(from: depositor) else {
|
||||||
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway)
|
SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only now pay for cryptographic verification; receivers verify again.
|
// Only now pay for cryptographic verification; receivers verify again.
|
||||||
guard event.isValidSignature() else {
|
guard event.isValidSignature() else {
|
||||||
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway)
|
SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +224,7 @@ final class GatewayService: ObservableObject {
|
|||||||
publish(event, geohash: carrier.geohash)
|
publish(event, geohash: carrier.geohash)
|
||||||
accepted = true
|
accepted = true
|
||||||
} else {
|
} else {
|
||||||
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event))
|
||||||
if accepted {
|
|
||||||
SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render on our own timeline what we actually accepted for
|
// Only render on our own timeline what we actually accepted for
|
||||||
@@ -247,7 +250,7 @@ final class GatewayService: ObservableObject {
|
|||||||
private func publish(_ event: NostrEvent, geohash: String) {
|
private func publish(_ event: NostrEvent, geohash: String) {
|
||||||
publishedEventIDs.insert(event.id)
|
publishedEventIDs.insert(event.id)
|
||||||
publishToRelays?(event, geohash)
|
publishToRelays?(event, geohash)
|
||||||
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
|
SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true when the item was actually stored for later publish.
|
/// Returns true when the item was actually stored for later publish.
|
||||||
@@ -255,7 +258,7 @@ final class GatewayService: ObservableObject {
|
|||||||
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||||
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||||
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
||||||
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway)
|
SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||||
@@ -298,26 +301,26 @@ final class GatewayService: ObservableObject {
|
|||||||
// event's own `#g` tag to match the carrier geohash.
|
// event's own `#g` tag to match the carrier geohash.
|
||||||
guard isFresh(event),
|
guard isFresh(event),
|
||||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
||||||
SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))…", category: .gateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||||
// mesh. Loop rule 2: rebroadcast each relay event at most once — but
|
// mesh. Loop rule 2 (self-echo): never rebroadcast an event this
|
||||||
// mark only AFTER it is actually sent (in `drainPendingDownlinks`), so
|
// gateway itself uplinked (`publishedEventIDs`) — it originated on this
|
||||||
// an event dropped by the queue overflow stays retryable on relay
|
// very mesh, so our own relay subscription echoing it back must not
|
||||||
|
// double the BLE airtime by pushing it out again. Loop rule 2
|
||||||
|
// (downlink): rebroadcast each genuine inbound relay event at most once
|
||||||
|
// — but mark only AFTER it is actually sent (in `drainPendingDownlinks`),
|
||||||
|
// so an event dropped by the queue overflow stays retryable on relay
|
||||||
// redelivery. Guard against a redelivery re-queueing an event that is
|
// redelivery. Guard against a redelivery re-queueing an event that is
|
||||||
// still waiting to be sent.
|
// still waiting to be sent.
|
||||||
guard !meshBroadcastEventIDs.contains(event.id),
|
guard !meshBroadcastEventIDs.contains(event.id),
|
||||||
|
!publishedEventIDs.contains(event.id),
|
||||||
!rebroadcastEventIDs.contains(event.id),
|
!rebroadcastEventIDs.contains(event.id),
|
||||||
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
||||||
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Verify before spending BLE airtime; receivers verify again.
|
// Verify before spending BLE airtime; receivers verify again.
|
||||||
guard event.isValidSignature() else {
|
guard event.isValidSignature() else { return }
|
||||||
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingDownlinks.append((event, geohash))
|
pendingDownlinks.append((event, geohash))
|
||||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||||
@@ -325,7 +328,6 @@ final class GatewayService: ObservableObject {
|
|||||||
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
||||||
// relay redelivery can still carry it.
|
// relay redelivery can still carry it.
|
||||||
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||||
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
|
|
||||||
}
|
}
|
||||||
drainPendingDownlinks()
|
drainPendingDownlinks()
|
||||||
}
|
}
|
||||||
@@ -342,7 +344,6 @@ final class GatewayService: ObservableObject {
|
|||||||
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||||
let payload = carrier.encode() else { continue }
|
let payload = carrier.encode() else { continue }
|
||||||
broadcastToMesh?(payload)
|
broadcastToMesh?(payload)
|
||||||
SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway)
|
|
||||||
// Mark-after-send: only now is the relay event definitively
|
// Mark-after-send: only now is the relay event definitively
|
||||||
// rebroadcast (loop rule 2).
|
// rebroadcast (loop rule 2).
|
||||||
rebroadcastEventIDs.insert(event.id)
|
rebroadcastEventIDs.insert(event.id)
|
||||||
@@ -362,11 +363,9 @@ final class GatewayService: ObservableObject {
|
|||||||
let oldest = downlinkSendTimes.min() ?? now()
|
let oldest = downlinkSendTimes.min() ?? now()
|
||||||
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||||
downlinkDrainScheduled = true
|
downlinkDrainScheduled = true
|
||||||
SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway)
|
|
||||||
let fire: @MainActor () -> Void = { [weak self] in
|
let fire: @MainActor () -> Void = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.downlinkDrainScheduled = false
|
self.downlinkDrainScheduled = false
|
||||||
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
|
|
||||||
self.drainPendingDownlinks()
|
self.drainPendingDownlinks()
|
||||||
}
|
}
|
||||||
if let scheduleDrainTimer {
|
if let scheduleDrainTimer {
|
||||||
@@ -421,7 +420,7 @@ final class GatewayService: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
||||||
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway)
|
SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,30 +466,3 @@ final class GatewayService: ObservableObject {
|
|||||||
&& geohash.allSatisfy { allowed.contains($0) }
|
&& geohash.allSatisfy { allowed.contains($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
|
||||||
/// evicted when full.
|
|
||||||
private 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,122 @@
|
|||||||
|
//
|
||||||
|
// GeohashChatActivityTracker.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Tracks actual chat-message activity per sampled geohash so the empty mesh
|
||||||
|
// timeline can point at a nearby channel where a conversation is happening —
|
||||||
|
// not merely where participants are present.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A recent chat message observed in a sampled geohash channel.
|
||||||
|
struct GeohashChatPreview: Equatable, Sendable {
|
||||||
|
let senderName: String
|
||||||
|
let content: String
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(senderName: String, content: String, timestamp: Date) {
|
||||||
|
self.senderName = senderName
|
||||||
|
self.content = content
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The liveliest nearby conversation, resolved against the user's regional
|
||||||
|
/// channels.
|
||||||
|
struct NearbyConversation: Equatable, Sendable {
|
||||||
|
let channel: GeohashChannel
|
||||||
|
/// Chat messages seen within the activity window.
|
||||||
|
let messageCount: Int
|
||||||
|
let lastMessage: GeohashChatPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records kind-20000 chat events seen by the background geohash sampling
|
||||||
|
/// subscriptions (blocked and self senders are filtered by the caller) and
|
||||||
|
/// answers "where nearby is a conversation actually happening?".
|
||||||
|
@MainActor
|
||||||
|
final class GeohashChatActivityTracker: ObservableObject {
|
||||||
|
static let shared = GeohashChatActivityTracker()
|
||||||
|
|
||||||
|
/// How far back a message still counts as "a conversation is happening".
|
||||||
|
private let window: TimeInterval
|
||||||
|
/// Per-geohash recent message timestamps (pruned to the window).
|
||||||
|
private var messageTimes: [String: [Date]] = [:]
|
||||||
|
/// Per-geohash newest message preview.
|
||||||
|
private var lastMessages: [String: GeohashChatPreview] = [:]
|
||||||
|
private let now: () -> Date
|
||||||
|
|
||||||
|
init(
|
||||||
|
window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds,
|
||||||
|
now: @escaping () -> Date = { Date() }
|
||||||
|
) {
|
||||||
|
self.window = window
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordChatMessage(
|
||||||
|
geohash: String,
|
||||||
|
senderName: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
let gh = geohash.lowercased()
|
||||||
|
let clamped = min(timestamp, now())
|
||||||
|
guard now().timeIntervalSince(clamped) < window else { return }
|
||||||
|
|
||||||
|
var times = messageTimes[gh] ?? []
|
||||||
|
times.append(clamped)
|
||||||
|
messageTimes[gh] = prune(times)
|
||||||
|
|
||||||
|
if let existing = lastMessages[gh], existing.timestamp > clamped {
|
||||||
|
// Keep the newer preview.
|
||||||
|
} else {
|
||||||
|
lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped)
|
||||||
|
}
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages seen in the window for one geohash.
|
||||||
|
func messageCount(for geohash: String) -> Int {
|
||||||
|
prune(messageTimes[geohash.lowercased()] ?? []).count
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastMessage(for geohash: String) -> GeohashChatPreview? {
|
||||||
|
let gh = geohash.lowercased()
|
||||||
|
guard messageCount(for: gh) > 0 else { return nil }
|
||||||
|
return lastMessages[gh]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The busiest channel with at least one chat message in the window.
|
||||||
|
/// Ties go to the more local (higher-precision) channel, so a lone
|
||||||
|
/// message on your block beats a lone message across the region.
|
||||||
|
func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? {
|
||||||
|
var best: NearbyConversation?
|
||||||
|
for channel in channels {
|
||||||
|
let count = messageCount(for: channel.geohash)
|
||||||
|
guard count > 0, let last = lastMessage(for: channel.geohash) else { continue }
|
||||||
|
let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last)
|
||||||
|
if let current = best {
|
||||||
|
let better = count > current.messageCount
|
||||||
|
|| (count == current.messageCount
|
||||||
|
&& channel.level.precision > current.channel.level.precision)
|
||||||
|
if better { best = candidate }
|
||||||
|
} else {
|
||||||
|
best = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
messageTimes.removeAll()
|
||||||
|
lastMessages.removeAll()
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prune(_ times: [Date]) -> [Date] {
|
||||||
|
let cutoff = now().addingTimeInterval(-window)
|
||||||
|
return times.filter { $0 >= cutoff }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Represents a participant in a geohash channel
|
/// Represents a participant in a geohash channel
|
||||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
struct GeoPerson: Identifiable, Equatable, Sendable {
|
||||||
public let id: String // pubkey hex (lowercased)
|
let id: String // pubkey hex (lowercased)
|
||||||
public let displayName: String
|
let displayName: String
|
||||||
public let lastSeen: Date
|
let lastSeen: Date
|
||||||
|
|
||||||
public init(id: String, displayName: String, lastSeen: Date) {
|
init(id: String, displayName: String, lastSeen: Date) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
@@ -23,7 +23,7 @@ public struct GeoPerson: Identifiable, Equatable, Sendable {
|
|||||||
|
|
||||||
/// Protocol for resolving display names and checking block status
|
/// Protocol for resolving display names and checking block status
|
||||||
@MainActor
|
@MainActor
|
||||||
public protocol GeohashParticipantContext: AnyObject {
|
protocol GeohashParticipantContext: AnyObject {
|
||||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
||||||
/// Returns true if the pubkey is blocked
|
/// Returns true if the pubkey is blocked
|
||||||
@@ -32,16 +32,16 @@ public protocol GeohashParticipantContext: AnyObject {
|
|||||||
|
|
||||||
/// Tracks participants across multiple geohash channels
|
/// Tracks participants across multiple geohash channels
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class GeohashParticipantTracker: ObservableObject {
|
final class GeohashParticipantTracker: ObservableObject {
|
||||||
|
|
||||||
/// Activity cutoff duration (defaults to 5 minutes)
|
/// Activity cutoff duration (defaults to 5 minutes)
|
||||||
public let activityCutoff: TimeInterval
|
let activityCutoff: TimeInterval
|
||||||
|
|
||||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
||||||
private var participants: [String: [String: Date]] = [:]
|
private var participants: [String: [String: Date]] = [:]
|
||||||
|
|
||||||
/// Currently visible people for the active geohash
|
/// Currently visible people for the active geohash
|
||||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
@Published private(set) var visiblePeople: [GeoPerson] = []
|
||||||
|
|
||||||
/// The currently active geohash (if any)
|
/// The currently active geohash (if any)
|
||||||
private var activeGeohash: String?
|
private var activeGeohash: String?
|
||||||
@@ -52,17 +52,17 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
/// Timer for periodic refresh
|
/// Timer for periodic refresh
|
||||||
private var refreshTimer: Timer?
|
private var refreshTimer: Timer?
|
||||||
|
|
||||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
||||||
self.activityCutoff = activityCutoff
|
self.activityCutoff = activityCutoff
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure with a context provider
|
/// Configure with a context provider
|
||||||
public func configure(context: GeohashParticipantContext) {
|
func configure(context: GeohashParticipantContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the currently active geohash
|
/// Set the currently active geohash
|
||||||
public func setActiveGeohash(_ geohash: String?) {
|
func setActiveGeohash(_ geohash: String?) {
|
||||||
activeGeohash = geohash
|
activeGeohash = geohash
|
||||||
if geohash == nil {
|
if geohash == nil {
|
||||||
visiblePeople = []
|
visiblePeople = []
|
||||||
@@ -72,13 +72,13 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record activity from a participant in the current active geohash
|
/// Record activity from a participant in the current active geohash
|
||||||
public func recordParticipant(pubkeyHex: String) {
|
func recordParticipant(pubkeyHex: String) {
|
||||||
guard let gh = activeGeohash else { return }
|
guard let gh = activeGeohash else { return }
|
||||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record activity from a participant in a specific geohash
|
/// Record activity from a participant in a specific geohash
|
||||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||||
let key = pubkeyHex.lowercased()
|
let key = pubkeyHex.lowercased()
|
||||||
var map = participants[geohash] ?? [:]
|
var map = participants[geohash] ?? [:]
|
||||||
map[key] = Date()
|
map[key] = Date()
|
||||||
@@ -94,7 +94,7 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a participant from all geohashes (used when blocking)
|
/// Remove a participant from all geohashes (used when blocking)
|
||||||
public func removeParticipant(pubkeyHex: String) {
|
func removeParticipant(pubkeyHex: String) {
|
||||||
let key = pubkeyHex.lowercased()
|
let key = pubkeyHex.lowercased()
|
||||||
for (gh, var map) in participants {
|
for (gh, var map) in participants {
|
||||||
map.removeValue(forKey: key)
|
map.removeValue(forKey: key)
|
||||||
@@ -104,14 +104,14 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get participant count for a specific geohash
|
/// Get participant count for a specific geohash
|
||||||
public func participantCount(for geohash: String) -> Int {
|
func participantCount(for geohash: String) -> Int {
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||||
let map = participants[geohash] ?? [:]
|
let map = participants[geohash] ?? [:]
|
||||||
return map.values.filter { $0 >= cutoff }.count
|
return map.values.filter { $0 >= cutoff }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the visible people list for the active geohash (read-only query)
|
/// Get the visible people list for the active geohash (read-only query)
|
||||||
public func getVisiblePeople() -> [GeoPerson] {
|
func getVisiblePeople() -> [GeoPerson] {
|
||||||
guard let gh = activeGeohash, let context = context else { return [] }
|
guard let gh = activeGeohash, let context = context else { return [] }
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||||
let map = (participants[gh] ?? [:])
|
let map = (participants[gh] ?? [:])
|
||||||
@@ -126,12 +126,12 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh the visible people list
|
/// Refresh the visible people list
|
||||||
public func refresh() {
|
func refresh() {
|
||||||
visiblePeople = getVisiblePeople()
|
visiblePeople = getVisiblePeople()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the periodic refresh timer
|
/// Start the periodic refresh timer
|
||||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
func startRefreshTimer(interval: TimeInterval = 30.0) {
|
||||||
stopRefreshTimer()
|
stopRefreshTimer()
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -141,19 +141,19 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the periodic refresh timer
|
/// Stop the periodic refresh timer
|
||||||
public func stopRefreshTimer() {
|
func stopRefreshTimer() {
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = nil
|
refreshTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all participant data
|
/// Clear all participant data
|
||||||
public func clear() {
|
func clear() {
|
||||||
participants.removeAll()
|
participants.removeAll()
|
||||||
visiblePeople = []
|
visiblePeople = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear participant data for a specific geohash
|
/// Clear participant data for a specific geohash
|
||||||
public func clear(geohash: String) {
|
func clear(geohash: String) {
|
||||||
participants.removeValue(forKey: geohash)
|
participants.removeValue(forKey: geohash)
|
||||||
if activeGeohash == geohash {
|
if activeGeohash == geohash {
|
||||||
visiblePeople = []
|
visiblePeople = []
|
||||||
|
|||||||
@@ -406,7 +406,6 @@ enum GroupCryptoError: Error, Equatable {
|
|||||||
case malformedPayload
|
case malformedPayload
|
||||||
case signingFailed
|
case signingFailed
|
||||||
case sealFailed
|
case sealFailed
|
||||||
case wrongEpoch
|
|
||||||
case decryptionFailed
|
case decryptionFailed
|
||||||
case badSenderSignature
|
case badSenderSignature
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,79 @@ import Foundation
|
|||||||
import Security
|
import Security
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
|
/// Default keychain for components that construct their own rather than
|
||||||
|
/// having one injected. Under test this is an in-memory keychain: the
|
||||||
|
/// xctest runner's code signature changes every build, so any read of a
|
||||||
|
/// real login-keychain item triggers a macOS password prompt that
|
||||||
|
/// "Always Allow" can never satisfy — and tests must never read or
|
||||||
|
/// mutate the developer's real keychain (`SecItemCopyMatching` can also
|
||||||
|
/// hang in test environments). Production behavior is unchanged.
|
||||||
|
static func makeDefault() -> 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 sharedTestKeychain }
|
||||||
|
#endif
|
||||||
|
return KeychainManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// One store per process, mirroring the real keychain: separate
|
||||||
|
/// default-constructed components (e.g. two NostrIdentityBridge
|
||||||
|
/// instances in BoardManager's publish and delete paths) must see each
|
||||||
|
/// other's writes, or they would derive different Nostr identities
|
||||||
|
/// under test.
|
||||||
|
private static let sharedTestKeychain = PreviewKeychainManager()
|
||||||
|
#endif
|
||||||
|
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
|
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||||
|
// device locked (identity-cache saves failed with -25308 throughout
|
||||||
|
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||||
|
// restoration must be able to read the noise keys before the user
|
||||||
|
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||||
|
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if os(iOS)
|
||||||
|
migrateAccessibilityIfNeeded()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||||
|
/// the right class on their own (saves are delete-then-add), but the
|
||||||
|
/// long-lived identity keys are written once and would otherwise stay
|
||||||
|
/// unreadable while the device is locked.
|
||||||
|
private func migrateAccessibilityIfNeeded() {
|
||||||
|
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||||
|
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service
|
||||||
|
]
|
||||||
|
let update: [String: Any] = [
|
||||||
|
kSecAttrAccessible as String: Self.itemAccessibility
|
||||||
|
]
|
||||||
|
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess, errSecItemNotFound:
|
||||||
|
// Nothing to migrate on a fresh install; both are terminal.
|
||||||
|
UserDefaults.standard.set(true, forKey: flag)
|
||||||
|
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||||
|
default:
|
||||||
|
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||||
|
// leave the flag unset so the next launch retries.
|
||||||
|
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
@@ -62,7 +131,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data,
|
kSecValueData as String: data,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
kSecAttrAccessible as String: Self.itemAccessibility,
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
kSecAttrLabel as String: "bitchat-\(key)"
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -212,11 +281,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Generic Operations
|
// MARK: - Generic Operations
|
||||||
|
|
||||||
private func save(_ value: String, forKey key: String) -> Bool {
|
|
||||||
guard let data = value.data(using: .utf8) else { return false }
|
|
||||||
return saveData(data, forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveData(_ data: Data, forKey key: String) -> Bool {
|
private func saveData(_ data: Data, forKey key: String) -> Bool {
|
||||||
// Delete any existing item first to ensure clean state
|
// Delete any existing item first to ensure clean state
|
||||||
_ = delete(forKey: key)
|
_ = delete(forKey: key)
|
||||||
@@ -227,7 +291,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data,
|
kSecValueData as String: data,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
kSecAttrAccessible as String: Self.itemAccessibility,
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
kSecAttrLabel as String: "bitchat-\(key)"
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -262,11 +326,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func retrieve(forKey key: String) -> String? {
|
|
||||||
guard let data = retrieveData(forKey: key) else { return nil }
|
|
||||||
return String(data: data, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func retrieveData(forKey key: String) -> Data? {
|
private func retrieveData(forKey key: String) -> Data? {
|
||||||
// Base query
|
// Base query
|
||||||
let base: [String: Any] = [
|
let base: [String: Any] = [
|
||||||
@@ -322,22 +381,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
func deleteAllPasswords() -> Bool {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add service if not empty
|
|
||||||
if !service.isEmpty {
|
|
||||||
query[kSecAttrService as String] = service
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
|
||||||
return status == errSecSuccess || status == errSecItemNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Delete ALL keychain data for panic mode
|
// Delete ALL keychain data for panic mode
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
||||||
@@ -522,4 +566,30 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete every item stored under a custom service
|
||||||
|
func deleteAll(service customService: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: customService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: customService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ struct LocationNotesDependencies {
|
|||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
// Fires when the geo relay directory refreshes; used to retry after "no relays".
|
// Fires when the geo relay directory refreshes; used to retry after "no relays".
|
||||||
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
|
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||||
|
/// Whether any of the target relays has a live connection — distinguishes
|
||||||
|
/// "loaded, empty" from "still connecting (Tor warming up)" when EOSE
|
||||||
|
/// fires without data. Defaults to true so tests keep legacy behavior.
|
||||||
|
var anyRelayConnected: @MainActor (_ relayUrls: [String]) -> Bool = { _ in true }
|
||||||
|
|
||||||
private static let idBridge = NostrIdentityBridge()
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
@@ -46,7 +50,10 @@ struct LocationNotesDependencies {
|
|||||||
relayDirectoryUpdates: NotificationCenter.default
|
relayDirectoryUpdates: NotificationCenter.default
|
||||||
.publisher(for: .geoRelayDirectoryDidRefresh)
|
.publisher(for: .geoRelayDirectoryDidRefresh)
|
||||||
.map { _ in () }
|
.map { _ in () }
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher(),
|
||||||
|
anyRelayConnected: { relayUrls in
|
||||||
|
NostrRelayManager.shared.isAnyRelayConnected(among: relayUrls)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +64,10 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
enum State: Equatable {
|
enum State: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case loading
|
case loading
|
||||||
|
/// The initial fetch timed out with zero target relays connected
|
||||||
|
/// (usually Tor still bootstrapping): not "empty", just not there
|
||||||
|
/// yet. Retries automatically once a relay comes up.
|
||||||
|
case connecting
|
||||||
case ready
|
case ready
|
||||||
case noRelays
|
case noRelays
|
||||||
}
|
}
|
||||||
@@ -67,6 +78,33 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let content: String
|
let content: String
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let nickname: String?
|
let nickname: String?
|
||||||
|
/// The matched `g` tag: the cell the note was posted to, which can be
|
||||||
|
/// a neighbor of the subscribed geohash.
|
||||||
|
let geohash: String
|
||||||
|
/// NIP-40 expiration, when the note carries one (dead drops do).
|
||||||
|
let expiresAt: Date?
|
||||||
|
/// Carries a `["t","urgent"]` tag (parity with urgent board posts).
|
||||||
|
let isUrgent: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String,
|
||||||
|
pubkey: String,
|
||||||
|
content: String,
|
||||||
|
createdAt: Date,
|
||||||
|
nickname: String?,
|
||||||
|
geohash: String,
|
||||||
|
expiresAt: Date? = nil,
|
||||||
|
isUrgent: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.pubkey = pubkey
|
||||||
|
self.content = content
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.nickname = nickname
|
||||||
|
self.geohash = geohash
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.isUrgent = isUrgent
|
||||||
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
let suffix = String(pubkey.suffix(4))
|
let suffix = String(pubkey.suffix(4))
|
||||||
@@ -82,9 +120,13 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
@Published private(set) var state: State = .loading
|
@Published private(set) var state: State = .loading
|
||||||
@Published private(set) var errorMessage: String?
|
@Published private(set) var errorMessage: String?
|
||||||
|
/// Public key of our per-geohash Nostr identity; identifies our own notes.
|
||||||
|
private var ownPubkey: String?
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
private var directoryUpdateCancellable: AnyCancellable?
|
private var directoryUpdateCancellable: AnyCancellable?
|
||||||
|
private var expiryPruneTimer: Timer?
|
||||||
|
private var connectivityRetryTimer: Timer?
|
||||||
private let dependencies: LocationNotesDependencies
|
private let dependencies: LocationNotesDependencies
|
||||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||||
|
|
||||||
@@ -104,10 +146,10 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let norm = geohash.lowercased()
|
let norm = geohash.lowercased()
|
||||||
self.geohash = norm
|
self.geohash = norm
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
// Validate geohash (building-level precision: 8 chars)
|
if !Geohash.isValidGeohash(norm) {
|
||||||
if !Geohash.isValidBuildingGeohash(norm) {
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
||||||
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
|
||||||
}
|
}
|
||||||
|
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
||||||
subscribe()
|
subscribe()
|
||||||
// The relay directory may load after init (remote fetch over Tor);
|
// The relay directory may load after init (remote fetch over Tor);
|
||||||
// retry automatically instead of staying stuck on "no relays".
|
// retry automatically instead of staying stuck on "no relays".
|
||||||
@@ -118,14 +160,41 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
self.subscribe()
|
self.subscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// NIP-40 notes can expire while displayed (a 24h dead drop crossing
|
||||||
|
// its boundary); ingest-time filtering alone would keep it visible
|
||||||
|
// until the subscription is recreated.
|
||||||
|
expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.pruneExpiredNotes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
expiryPruneTimer?.invalidate()
|
||||||
|
connectivityRetryTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops notes whose NIP-40 expiry has passed. Their ids stay in
|
||||||
|
/// `noteIDs` so a relay replay cannot resurrect them.
|
||||||
|
func pruneExpiredNotes() {
|
||||||
|
let now = dependencies.now()
|
||||||
|
let expired = notes.contains { note in
|
||||||
|
if let expiresAt = note.expiresAt { return expiresAt <= now }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard expired else { return }
|
||||||
|
notes.removeAll { note in
|
||||||
|
if let expiresAt = note.expiresAt { return expiresAt <= now }
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
// Validate geohash (building-level precision: 8 chars)
|
guard Geohash.isValidGeohash(norm) else {
|
||||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session)
|
||||||
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
@@ -137,6 +206,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
|
ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
@@ -163,6 +233,8 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
private func subscribe() {
|
private func subscribe() {
|
||||||
state = .loading
|
state = .loading
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
connectivityRetryTimer?.invalidate()
|
||||||
|
connectivityRetryTimer = nil
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
@@ -193,14 +265,19 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag - accept any of our 9 geohashes
|
// Ensure matching tag - accept any of our 9 geohashes
|
||||||
guard event.tags.contains(where: { tag in
|
guard let matchedGeohash = event.tags.first(where: { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||||
}) else { return }
|
})?[1].lowercased() else { return }
|
||||||
guard !self.noteIDs.contains(event.id) else { return }
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
|
// NIP-40: relays are not required to enforce expiration — drop
|
||||||
|
// expired notes client-side so 24h dead drops actually vanish.
|
||||||
|
let expiresAt = Self.expirationDate(of: event)
|
||||||
|
if let expiresAt, expiresAt <= self.dependencies.now() { return }
|
||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" }
|
||||||
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
self.enforceMemoryCap()
|
self.enforceMemoryCap()
|
||||||
@@ -208,14 +285,51 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
}, { [weak self] in
|
}, { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.initialLoadComplete = true
|
self.initialLoadComplete = true
|
||||||
if self.state != .noRelays {
|
guard self.state != .noRelays else { return }
|
||||||
|
// EOSE with no data and zero connected target relays means the
|
||||||
|
// 10s fallback fired while Tor was still warming up — showing
|
||||||
|
// "no notes" would be a lie. Wait visibly and retry.
|
||||||
|
if self.notes.isEmpty, !self.dependencies.anyRelayConnected(relays) {
|
||||||
|
self.state = .connecting
|
||||||
|
self.scheduleConnectivityRetry(relays: relays)
|
||||||
|
} else {
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a location note for the current geohash using the per-geohash identity.
|
/// While `.connecting`, poll for a live target relay and re-subscribe as
|
||||||
func send(content: String, nickname: String) {
|
/// soon as one appears (fresh REQ, fresh EOSE tracking). The poll dies
|
||||||
|
/// with the state: any subscribe/cancel invalidates it.
|
||||||
|
private func scheduleConnectivityRetry(relays: [String]) {
|
||||||
|
connectivityRetryTimer?.invalidate()
|
||||||
|
connectivityRetryTimer = Timer.scheduledTimer(
|
||||||
|
withTimeInterval: TransportConfig.uiGeoNotesConnectivityRetrySeconds,
|
||||||
|
repeats: true
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.retryIfRelaysAvailable(relays: relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryIfRelaysAvailable(relays: [String]) {
|
||||||
|
guard state == .connecting else {
|
||||||
|
connectivityRetryTimer?.invalidate()
|
||||||
|
connectivityRetryTimer = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard dependencies.anyRelayConnected(relays) else { return }
|
||||||
|
connectivityRetryTimer?.invalidate()
|
||||||
|
connectivityRetryTimer = nil
|
||||||
|
SecureLogger.debug("LocationNotesManager: relay came up, retrying notes fetch for \(geohash)", category: .session)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a location note for the current geohash using the per-geohash
|
||||||
|
/// identity, optionally expiring via NIP-40 (dead drops pass 24h; the
|
||||||
|
/// composer's ∞ option passes nil) and optionally tagged urgent.
|
||||||
|
func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) {
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
guard !relays.isEmpty else {
|
guard !relays.isEmpty else {
|
||||||
@@ -230,7 +344,9 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: id,
|
senderIdentity: id,
|
||||||
nickname: nickname
|
nickname: nickname,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
urgent: urgent
|
||||||
)
|
)
|
||||||
dependencies.sendEvent(event, relays)
|
dependencies.sendEvent(event, relays)
|
||||||
// Optimistic local-echo
|
// Optimistic local-echo
|
||||||
@@ -239,7 +355,10 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
pubkey: id.publicKeyHex,
|
pubkey: id.publicKeyHex,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
nickname: nickname
|
nickname: nickname,
|
||||||
|
geohash: geohash,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
isUrgent: urgent
|
||||||
)
|
)
|
||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
@@ -252,6 +371,44 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the note was published by this device's identity for the
|
||||||
|
/// current geohash (and can therefore be deleted with NIP-09).
|
||||||
|
func isOwnNote(_ note: Note) -> Bool {
|
||||||
|
guard let ownPubkey else { return false }
|
||||||
|
return note.pubkey == ownPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests NIP-09 deletion of one of our own notes and removes it locally.
|
||||||
|
@discardableResult
|
||||||
|
func delete(note: Note) -> Bool {
|
||||||
|
guard isOwnNote(note) else { return false }
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let identity = try dependencies.deriveIdentity(geohash)
|
||||||
|
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity)
|
||||||
|
dependencies.sendEvent(deletion, relays)
|
||||||
|
// Keep the id in noteIDs so a relay replay can't resurrect it.
|
||||||
|
notes.removeAll { $0.id == note.id }
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NIP-40 `expiration` tag as a date, if the event carries one.
|
||||||
|
static func expirationDate(of event: NostrEvent) -> Date? {
|
||||||
|
guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }),
|
||||||
|
let seconds = TimeInterval(tag[1])
|
||||||
|
else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: seconds)
|
||||||
|
}
|
||||||
|
|
||||||
/// Enforces defensive memory cap on notes array (keeps newest).
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||||
private func enforceMemoryCap() {
|
private func enforceMemoryCap() {
|
||||||
if notes.count > maxNotesInMemory {
|
if notes.count > maxNotesInMemory {
|
||||||
@@ -261,12 +418,50 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicitly cancel subscription and release resources.
|
/// One-shot dead-drop publish without holding a subscription: pins a
|
||||||
|
/// note to `geohash` that expires via NIP-40. Returns false when no geo
|
||||||
|
/// relays are known or signing fails.
|
||||||
|
@MainActor
|
||||||
|
static func postDrop(
|
||||||
|
content: String,
|
||||||
|
nickname: String,
|
||||||
|
geohash: String,
|
||||||
|
expiry: TimeInterval = TransportConfig.locationDropExpirySeconds,
|
||||||
|
dependencies: LocationNotesDependencies = .live
|
||||||
|
) -> Bool {
|
||||||
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return false }
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let identity = try dependencies.deriveIdentity(geohash)
|
||||||
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
|
content: trimmed,
|
||||||
|
geohash: geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: nickname,
|
||||||
|
expiresAt: dependencies.now().addingTimeInterval(expiry)
|
||||||
|
)
|
||||||
|
dependencies.sendEvent(event, relays)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly cancel the subscription. The prune timer stays alive (it
|
||||||
|
/// holds only a weak self) so a reused instance — the notices sheet
|
||||||
|
/// cancels on tab switch and refreshes on return — keeps pruning.
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
connectivityRetryTimer?.invalidate()
|
||||||
|
connectivityRetryTimer = nil
|
||||||
state = .idle
|
state = .idle
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// LocationNotesSettings.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// User preference for location notes (dead drops): leaving notes pinned to
|
||||||
|
/// nearby places with /drop and surfacing notes others left here. On by
|
||||||
|
/// default, but everything it powers additionally requires location
|
||||||
|
/// permission — the toggle in app info is the kill switch.
|
||||||
|
enum LocationNotesSettings {
|
||||||
|
private static let enabledKey = "locationNotes.enabled"
|
||||||
|
|
||||||
|
/// Fired on every toggle write so live consumers (the nearby-notes
|
||||||
|
/// counter) can drop or restart their relay subscription immediately.
|
||||||
|
static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange")
|
||||||
|
|
||||||
|
static var enabled: Bool {
|
||||||
|
get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true }
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(newValue, forKey: enabledKey)
|
||||||
|
NotificationCenter.default.post(name: didChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,9 +101,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
private let cl: LocationStateManaging
|
private let cl: LocationStateManaging
|
||||||
private let geocoder: LocationStateGeocoding
|
private let geocoder: LocationStateGeocoding
|
||||||
private var lastLocation: CLLocation?
|
|
||||||
private var refreshTimer: Timer?
|
private var refreshTimer: Timer?
|
||||||
private var isGeocoding: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Persistence Keys
|
// MARK: - Persistence Keys
|
||||||
|
|
||||||
@@ -268,7 +266,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
func beginLiveRefresh(interval _: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||||
guard permissionState == .authorized else { return }
|
guard permissionState == .authorized else { return }
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = nil
|
refreshTimer = nil
|
||||||
@@ -385,7 +383,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
guard let loc = locations.last else { return }
|
guard let loc = locations.last else { return }
|
||||||
lastLocation = loc
|
|
||||||
computeChannels(from: loc.coordinate)
|
computeChannels(from: loc.coordinate)
|
||||||
reverseGeocodeLocation(loc)
|
reverseGeocodeLocation(loc)
|
||||||
}
|
}
|
||||||
@@ -441,10 +438,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
private func reverseGeocodeLocation(_ location: CLLocation) {
|
private func reverseGeocodeLocation(_ location: CLLocation) {
|
||||||
geocoder.cancelGeocode()
|
geocoder.cancelGeocode()
|
||||||
isGeocoding = true
|
|
||||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.isGeocoding = false
|
|
||||||
if let pm = placemarks?.first {
|
if let pm = placemarks?.first {
|
||||||
let names = self.locationNamesByLevel(from: pm)
|
let names = self.locationNamesByLevel(from: pm)
|
||||||
Task { @MainActor in self.locationNames = names }
|
Task { @MainActor in self.locationNames = names }
|
||||||
@@ -632,15 +627,5 @@ extension LocationStateManager {
|
|||||||
func toggle(_ geohash: String) {
|
func toggle(_ geohash: String) {
|
||||||
toggleBookmark(geohash)
|
toggleBookmark(geohash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
|
|
||||||
func add(_ geohash: String) {
|
|
||||||
addBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
|
|
||||||
func remove(_ geohash: String) {
|
|
||||||
removeBookmark(geohash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// MeshEchoSettings.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Watermark for "heard here earlier" echoes: clearing the mesh timeline
|
||||||
|
/// (triple-tap or /clear) records the moment, and the next launch only
|
||||||
|
/// re-seeds archived messages heard after it. The archive itself is left
|
||||||
|
/// alone — the device keeps carrying those messages for peers; the user
|
||||||
|
/// just doesn't want to see them again.
|
||||||
|
enum MeshEchoSettings {
|
||||||
|
private static let clearedThroughKey = "meshEchoes.clearedThrough"
|
||||||
|
|
||||||
|
static var clearedThrough: Date? {
|
||||||
|
get { UserDefaults.standard.object(forKey: clearedThroughKey) as? Date }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: clearedThroughKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: clearedThroughKey)
|
||||||
|
}
|
||||||
|
}
|
||||||