Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021af3a22d | ||
|
|
8c9c1cc6ac | ||
|
|
55d51ba084 | ||
|
|
dc99d641c8 | ||
|
|
020de96519 | ||
|
|
d499c3e415 | ||
|
|
4baeaab717 | ||
|
|
74b8a47d02 | ||
|
|
ef848857b7 | ||
|
|
d5cf64f99e | ||
|
|
35fb9fdd42 | ||
|
|
3e9230229d | ||
|
|
78a291ab77 | ||
|
|
eacd8f0750 | ||
|
|
6886035632 | ||
|
|
e642f696cb |
@@ -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.6.0
|
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
|
||||||
@@ -42,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
|
||||||
@@ -51,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,
|
||||||
@@ -221,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 |
@@ -104,13 +104,21 @@ 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) {
|
||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
|
|||||||
@@ -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,7 +121,6 @@ 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()
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ struct NostrProtocol {
|
|||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
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
|
||||||
@@ -256,6 +260,84 @@ 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
|
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
||||||
/// drop the note in step with a bridged board post's expiry.
|
/// drop the note in step with a bridged board post's expiry.
|
||||||
@@ -264,7 +346,8 @@ struct NostrProtocol {
|
|||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil,
|
nickname: String? = nil,
|
||||||
expiresAt: Date? = 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 {
|
||||||
@@ -273,6 +356,9 @@ struct NostrProtocol {
|
|||||||
if let expiresAt {
|
if let expiresAt {
|
||||||
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
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(),
|
||||||
@@ -548,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
|
||||||
@@ -708,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
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ enum NoisePayloadType: UInt8 {
|
|||||||
// Private groups (0x04/0x05 reserved by other features)
|
// Private groups (0x04/0x05 reserved by other features)
|
||||||
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
|
||||||
@@ -90,6 +92,7 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
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"
|
||||||
@@ -127,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?)
|
||||||
@@ -150,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,14 +10,6 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Validates a geohash string for building-level precision (8 characters).
|
|
||||||
/// - Parameter geohash: The geohash string to validate
|
|
||||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
|
||||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
|
||||||
guard geohash.count == 8 else { return false }
|
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates a geohash string at any channel precision (1-12 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 a non-empty base32 geohash of at most 12 characters
|
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +11,6 @@ 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?) {
|
||||||
@@ -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,25 +46,32 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// Returns `false` when the packet fails sender authentication and must
|
||||||
|
/// not be relayed onward. Every other outcome returns `true`: files
|
||||||
|
/// 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)
|
||||||
}
|
}
|
||||||
@@ -75,16 +84,16 @@ final class BLEFileTransferHandler {
|
|||||||
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
|
||||||
@@ -97,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 {
|
||||||
@@ -113,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -78,11 +78,22 @@ 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
|
||||||
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
let validationPeerID = packet.isRSR ? receivedFromPeerID : 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, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage:
|
// 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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import UIKit
|
|||||||
/// BLEService — Bluetooth Mesh Transport
|
/// BLEService — Bluetooth Mesh Transport
|
||||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||||
/// - A lightweight `peerSnapshotPublisher` is provided for non-UI services.
|
|
||||||
final class BLEService: NSObject {
|
final class BLEService: NSObject {
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
@@ -38,6 +37,10 @@ final class BLEService: NSObject {
|
|||||||
// 1. Consolidated BLE link tracking for both central and peripheral roles.
|
// 1. Consolidated BLE link tracking for both central and peripheral roles.
|
||||||
private var linkStateStore = BLELinkStateStore()
|
private var linkStateStore = BLELinkStateStore()
|
||||||
|
|
||||||
|
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
||||||
|
// store): entries older than the cooldown are pruned on insert.
|
||||||
|
private var lastLinkRebindAt: [String: Date] = [:]
|
||||||
|
|
||||||
// BCH-01-004: Rate-limiting for subscription-triggered announces.
|
// BCH-01-004: Rate-limiting for subscription-triggered announces.
|
||||||
private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
|
private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
|
||||||
|
|
||||||
@@ -81,7 +84,11 @@ final class BLEService: NSObject {
|
|||||||
// for every announce. `directedToUs` distinguishes an uplink deposit
|
// for every announce. `directedToUs` distinguishes an uplink deposit
|
||||||
// addressed to this device from a downlink broadcast.
|
// addressed to this device from a downlink broadcast.
|
||||||
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
|
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
|
||||||
|
/// Fired (off-main) when a signature-verified announce is processed —
|
||||||
|
/// the bridge courier watch refreshes its tag set on new arrivals.
|
||||||
|
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
|
||||||
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
|
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
|
||||||
|
private var localBridgeGeohash: String? // collectionsQueue
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||||
@@ -490,13 +497,6 @@ final class BLEService: NSObject {
|
|||||||
weak var eventDelegate: TransportEventDelegate?
|
weak var eventDelegate: TransportEventDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
// MARK: Peer snapshots publisher (non-UI convenience)
|
|
||||||
|
|
||||||
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
|
||||||
peerSnapshotSubject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||||||
collectionsQueue.sync {
|
collectionsQueue.sync {
|
||||||
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
||||||
@@ -712,6 +712,33 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reachable peers currently advertising the `.bridge` capability.
|
||||||
|
func reachableBridgePeers() -> [PeerID] {
|
||||||
|
let now = Date()
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.peers(advertising: .bridge)
|
||||||
|
.filter { peerRegistry.isReachable($0, now: now) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rendezvous cell advertised by a bridge-capable peer's announce.
|
||||||
|
func advertisedBridgeGeohash() -> String? {
|
||||||
|
collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rendezvous cell this device advertises in its own announces while
|
||||||
|
/// bridging with the gateway toggle on. Set from the main actor; the
|
||||||
|
/// value rides the next (forced) announce.
|
||||||
|
func setLocalBridgeGeohash(_ cell: String?) {
|
||||||
|
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
|
||||||
|
guard localBridgeGeohash != cell else { return false }
|
||||||
|
localBridgeGeohash = cell
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard changed else { return }
|
||||||
|
sendAnnounce(forceSend: true)
|
||||||
|
}
|
||||||
|
|
||||||
func getPeerNicknames() -> [PeerID: String] {
|
func getPeerNicknames() -> [PeerID: String] {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||||||
@@ -1228,7 +1255,57 @@ final class BLEService: NSObject {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
|
// MARK: - Archived public messages ("heard here earlier")
|
||||||
|
|
||||||
|
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
||||||
|
guard let sync = gossipSyncManager else {
|
||||||
|
Task { @MainActor in completion([]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sync.collectPublicMessagePackets { [weak self] packets in
|
||||||
|
guard let self = self else {
|
||||||
|
Task { @MainActor in completion([]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Signature verification and registry lookups run on messageQueue
|
||||||
|
// like the live receive path.
|
||||||
|
self.messageQueue.async {
|
||||||
|
let decoded = packets
|
||||||
|
.compactMap { self.decodeArchivedPublicMessage($0) }
|
||||||
|
.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
Task { @MainActor in completion(decoded) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? {
|
||||||
|
guard packet.type == MessageType.message.rawValue,
|
||||||
|
let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty
|
||||||
|
else { return nil }
|
||||||
|
let senderPeerID = PeerID(hexData: packet.senderID)
|
||||||
|
let peers = collectionsQueue.sync { peerRegistry.snapshotByID }
|
||||||
|
// Archived senders are usually long gone, so the signature-derived
|
||||||
|
// identity is the best shot at a name; a live registry entry is
|
||||||
|
// next; anonymous fallback matches the live path.
|
||||||
|
let nickname = signedSenderDisplayName(for: packet, from: senderPeerID)
|
||||||
|
?? BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: senderPeerID,
|
||||||
|
localPeerID: myPeerID,
|
||||||
|
localNickname: myNickname,
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
)
|
||||||
|
?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID)
|
||||||
|
return ArchivedPublicMessage(
|
||||||
|
packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(),
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
senderNickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
fileTransferHandler.handle(packet, from: peerID)
|
fileTransferHandler.handle(packet, from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1247,6 +1324,9 @@ final class BLEService: NSObject {
|
|||||||
guard let self = self else { return [:] }
|
guard let self = self else { return [:] }
|
||||||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||||||
},
|
},
|
||||||
|
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||||||
|
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||||
|
},
|
||||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||||
},
|
},
|
||||||
@@ -1316,7 +1396,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
|
||||||
_ = collectionsQueue.sync(flags: .barrier) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
// Remove the peer when they leave
|
// Remove the peer when they leave
|
||||||
peerRegistry.remove(peerID)
|
peerRegistry.remove(peerID)
|
||||||
@@ -1346,8 +1426,12 @@ final class BLEService: NSObject {
|
|||||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||||
|
|
||||||
let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync {
|
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
|
||||||
(peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities))
|
(
|
||||||
|
peerRegistry.connectedRoutingData,
|
||||||
|
PeerCapabilities.localSupported.union(runtimeCapabilities),
|
||||||
|
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let announcement = AnnouncementPacket(
|
let announcement = AnnouncementPacket(
|
||||||
@@ -1355,7 +1439,8 @@ final class BLEService: NSObject {
|
|||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub,
|
signingPublicKey: signingPub,
|
||||||
directNeighbors: connectedPeerIDs,
|
directNeighbors: connectedPeerIDs,
|
||||||
capabilities: advertisedCapabilities
|
capabilities: advertisedCapabilities,
|
||||||
|
bridgeGeohash: advertisedBridgeCell
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = announcement.encode() else {
|
guard let payload = announcement.encode() else {
|
||||||
@@ -1456,6 +1541,55 @@ final class BLEService: NSObject {
|
|||||||
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
|
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Live Voice (PTT)
|
||||||
|
|
||||||
|
/// Sends one live voice-burst packet inside the Noise session. Unlike
|
||||||
|
/// `sendNoisePayload` this never queues behind a handshake: live audio is
|
||||||
|
/// only useful now, so without an established session frames are dropped.
|
||||||
|
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.noiseService.hasEstablishedSession(with: peerID) else {
|
||||||
|
SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode()
|
||||||
|
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to send voice frame: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcasts one live voice-burst packet to the public mesh, signed like
|
||||||
|
/// a public message so receivers can authenticate the talker. Ephemeral:
|
||||||
|
/// never tracked for gossip sync (stale audio is worthless to replay).
|
||||||
|
func sendVoiceFrameBroadcast(_ burstContent: Data) {
|
||||||
|
guard !burstContent.isEmpty else { return }
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.voiceFrame.rawValue,
|
||||||
|
senderID: self.myPeerIDData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: burstContent,
|
||||||
|
signature: nil,
|
||||||
|
ttl: self.messageTTL
|
||||||
|
)
|
||||||
|
guard let signedPacket = self.noiseService.signPacket(packet) else {
|
||||||
|
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Pre-mark our own broadcast as processed to avoid handling a
|
||||||
|
// relayed self copy.
|
||||||
|
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||||||
|
self.messageDeduplicator.markProcessed(dedupID)
|
||||||
|
self.broadcastPacket(signedPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
// Appends to the encryption service's handler array, so this never
|
// Appends to the encryption service's handler array, so this never
|
||||||
// displaces the callbacks installed by installNoiseSessionCallbacks.
|
// displaces the callbacks installed by installNoiseSessionCallbacks.
|
||||||
@@ -1923,6 +2057,28 @@ extension BLEService {
|
|||||||
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
|
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||||
|
bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_centralBinding(_ centralUUID: String) -> PeerID? {
|
||||||
|
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
isConnected: true,
|
||||||
|
noisePublicKey: nil,
|
||||||
|
signingPublicKey: nil,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: Date()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func _test_shouldRediscoverBitChatService(
|
static func _test_shouldRediscoverBitChatService(
|
||||||
invalidatedServiceUUIDs: [CBUUID],
|
invalidatedServiceUUIDs: [CBUUID],
|
||||||
cachedServiceUUIDs: [CBUUID]?
|
cachedServiceUUIDs: [CBUUID]?
|
||||||
@@ -2096,23 +2252,27 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
|
private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
|
||||||
let senderID = PeerID(hexData: packet.senderID)
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
|
|
||||||
if packet.type != MessageType.announce.rawValue {
|
if packet.type != MessageType.announce.rawValue {
|
||||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
if packet.type == MessageType.announce.rawValue {
|
if packet.type == MessageType.announce.rawValue,
|
||||||
if packet.ttl == messageTTL {
|
packet.ttl == messageTTL {
|
||||||
|
// Only bind an unbound link here: this runs before signature
|
||||||
|
// verification, so a bound link must not be re-bound by a raw
|
||||||
|
// announce (spoofable). Rotation rebinds happen after the announce
|
||||||
|
// verifies (rebindLinkAfterVerifiedDirectAnnounce).
|
||||||
|
let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID)
|
||||||
|
if boundPeerID == nil || boundPeerID == senderID {
|
||||||
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
|
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
|
||||||
refreshLocalTopology()
|
refreshLocalTopology()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleReceivedPacket(packet, from: peerID)
|
handleReceivedPacket(packet, from: peerID)
|
||||||
} else {
|
|
||||||
handleReceivedPacket(packet, from: peerID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
@@ -2474,9 +2634,14 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
if packet.type == MessageType.announce.rawValue,
|
if packet.type == MessageType.announce.rawValue,
|
||||||
packet.ttl == messageTTL {
|
packet.ttl == messageTTL {
|
||||||
|
// Same rule as the peripheral path: raw announces only bind
|
||||||
|
// unbound links; rotation rebinds require a verified announce.
|
||||||
|
let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||||
|
if boundPeerID == nil || boundPeerID == claimedSenderID {
|
||||||
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
|
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
|
||||||
refreshLocalTopology()
|
refreshLocalTopology()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
|
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
|
||||||
return
|
return
|
||||||
@@ -2937,6 +3102,92 @@ extension BLEService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Courier over the bridge
|
||||||
|
|
||||||
|
/// Seals `content` into a courier envelope for relay parking (a bridge
|
||||||
|
/// courier drop). Same sealing rules as `sendCourierMessage` — prekey
|
||||||
|
/// (v2) when a verified bundle is cached, static Noise X (v1) otherwise —
|
||||||
|
/// but carry-only: a relay copy never sprays.
|
||||||
|
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? {
|
||||||
|
guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let now = Date()
|
||||||
|
let sealed: Data
|
||||||
|
let prekeyID: UInt32?
|
||||||
|
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||||||
|
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||||||
|
prekeyID = prekey.id
|
||||||
|
} else {
|
||||||
|
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||||
|
prekeyID = nil
|
||||||
|
}
|
||||||
|
return CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: recipientNoiseKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
|
),
|
||||||
|
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||||
|
ciphertext: sealed,
|
||||||
|
copies: 1,
|
||||||
|
prekeyID: prekeyID
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a courier envelope that arrived as a bridge drop (relay fetch,
|
||||||
|
/// not a directed mesh packet). Returns false when the rotating tag does
|
||||||
|
/// not match our static key — a drop for someone else, or a stale tag.
|
||||||
|
/// The inner Noise X seal authenticates the sender; there is no packet
|
||||||
|
/// signature to check on this path.
|
||||||
|
@discardableResult
|
||||||
|
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
|
||||||
|
guard !envelope.isExpired else { return false }
|
||||||
|
let myKey = noiseService.getStaticPublicKeyData()
|
||||||
|
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
openCourierEnvelope(envelope)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands a bridge-fetched envelope directly to the matching local peer
|
||||||
|
/// as a directed courier packet. Delivery-only by design: the recipient's
|
||||||
|
/// tag matched, so this never lands in a stranger's carry quota.
|
||||||
|
@discardableResult
|
||||||
|
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
|
||||||
|
guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false }
|
||||||
|
guard let payload = envelope.encode() else { return false }
|
||||||
|
let packet = makeCourierPacket(payload, to: peerID)
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
self?.sendPacketDirected(packet, to: peerID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our own Noise static public key (for computing our courier tags).
|
||||||
|
func myNoiseStaticPublicKey() -> Data {
|
||||||
|
noiseService.getStaticPublicKeyData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verified reachable peers with known Noise keys — the set a bridge
|
||||||
|
/// gateway watches courier drops for.
|
||||||
|
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] {
|
||||||
|
let now = Date()
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.snapshotByID.values.compactMap { info in
|
||||||
|
guard info.isVerifiedNickname,
|
||||||
|
let key = info.noisePublicKey,
|
||||||
|
peerRegistry.isReachable(info.peerID, now: now) else { return nil }
|
||||||
|
return (info.peerID, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The prekey to seal a courier message with, or nil to fall back to
|
/// The prekey to seal a courier message with, or nil to fall back to
|
||||||
/// static sealing. The real signal is a verified, unexpired bundle with a
|
/// static sealing. The real signal is a verified, unexpired bundle with a
|
||||||
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
|
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
|
||||||
@@ -3335,7 +3586,7 @@ extension BLEService {
|
|||||||
/// Transport-level handling for a received nostrCarrier packet; policy
|
/// Transport-level handling for a received nostrCarrier packet; policy
|
||||||
/// (verification of the carried event, quotas, loop prevention) lives in
|
/// (verification of the carried event, quotas, loop prevention) lives in
|
||||||
/// `GatewayService` behind `onNostrCarrierPacket`.
|
/// `GatewayService` behind `onNostrCarrierPacket`.
|
||||||
private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) {
|
||||||
let senderID = PeerID(hexData: packet.senderID)
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
let directedToUs: Bool
|
let directedToUs: Bool
|
||||||
if let recipientID = packet.recipientID {
|
if let recipientID = packet.recipientID {
|
||||||
@@ -3947,7 +4198,10 @@ extension BLEService {
|
|||||||
handleFragment(packet, from: senderID)
|
handleFragment(packet, from: senderID)
|
||||||
|
|
||||||
case .fileTransfer:
|
case .fileTransfer:
|
||||||
handleFileTransfer(packet, from: senderID)
|
// Broadcast files that fail sender authentication must not spread
|
||||||
|
// to downstream (possibly older, ungated) nodes; skip the relay
|
||||||
|
// step below, like invalid board posts and voice frames.
|
||||||
|
guard handleFileTransfer(packet, from: senderID) else { return }
|
||||||
|
|
||||||
case .courierEnvelope:
|
case .courierEnvelope:
|
||||||
handleCourierEnvelope(packet, from: peerID)
|
handleCourierEnvelope(packet, from: peerID)
|
||||||
@@ -3964,6 +4218,11 @@ extension BLEService {
|
|||||||
case .nostrCarrier:
|
case .nostrCarrier:
|
||||||
handleNostrCarrier(packet, from: peerID)
|
handleNostrCarrier(packet, from: peerID)
|
||||||
|
|
||||||
|
case .voiceFrame:
|
||||||
|
// Rejected frames (unsigned/stale/spoofed) must not spread; skip
|
||||||
|
// the relay step below, like invalid board posts.
|
||||||
|
guard handleVoiceFrame(packet, from: senderID) else { return }
|
||||||
|
|
||||||
case .ping:
|
case .ping:
|
||||||
// Rate limiting must key on the ingress link (`peerID`), not the
|
// Rate limiting must key on the ingress link (`peerID`), not the
|
||||||
// packet-claimed sender: pings are unsigned, so `senderID` is
|
// packet-claimed sender: pings are unsigned, so `senderID` is
|
||||||
@@ -4042,6 +4301,18 @@ extension BLEService {
|
|||||||
drainPendingPrekeyBundles(for: result.peerID)
|
drainPendingPrekeyBundles(for: result.peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A verified direct announce proves the sender owns the link it came
|
||||||
|
// in on: heal any stale binding left by a peer-ID rotation.
|
||||||
|
if let result, result.isVerified, result.isDirectAnnounce {
|
||||||
|
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge courier watch: a verified announce may add a peer whose
|
||||||
|
// relay-parked drops we should start watching for.
|
||||||
|
if let result, result.isVerified {
|
||||||
|
onVerifiedPeerAnnounce?(result.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
// Courier work: an announce is the moment we learn a peer's Noise
|
// Courier work: an announce is the moment we learn a peer's Noise
|
||||||
// static key, so check whether we're carrying mail addressed to them
|
// static key, so check whether we're carrying mail addressed to them
|
||||||
// (or spray-able mail they could carry). Verified announces only.
|
// (or spray-able mail they could carry). Verified announces only.
|
||||||
@@ -4061,6 +4332,85 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a peer relaunches it rotates its ephemeral peer ID, but an
|
||||||
|
/// already-open BLE connection keeps its old peripheral/central→peerID
|
||||||
|
/// binding. Until that binding heals, the rotated peer shows up twice in
|
||||||
|
/// the peer list and its directed traffic on this link is dropped as
|
||||||
|
/// spoofed. A signature-verified direct announce proves the claimed
|
||||||
|
/// sender owns the link it arrived on, so rebind the link to the new ID
|
||||||
|
/// and retire the old identity.
|
||||||
|
private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||||
|
guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return }
|
||||||
|
bleQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let linkUUID: String
|
||||||
|
let previousPeerID: PeerID?
|
||||||
|
switch link {
|
||||||
|
case .peripheral(let peripheralUUID):
|
||||||
|
linkUUID = peripheralUUID
|
||||||
|
previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID)
|
||||||
|
case .central(let centralUUID):
|
||||||
|
linkUUID = centralUUID
|
||||||
|
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||||
|
}
|
||||||
|
guard let previousPeerID, previousPeerID != peerID else { return }
|
||||||
|
|
||||||
|
// The signature does not authenticate directness (TTL is excluded
|
||||||
|
// from signing because relays mutate it), so a "verified direct"
|
||||||
|
// announce can be a replay of another peer's fresh announce with
|
||||||
|
// its TTL restored. Contain what a forged rebind could do:
|
||||||
|
// never steal an identity another live link already owns, and
|
||||||
|
// allow at most one rebind per link per cooldown window so two
|
||||||
|
// identities can't fight over a link in a replay flip-flop.
|
||||||
|
guard self.linkStateStore.links(to: peerID).isEmpty else {
|
||||||
|
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
self.lastLinkRebindAt = self.lastLinkRebindAt.filter {
|
||||||
|
now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
|
||||||
|
}
|
||||||
|
guard self.lastLinkRebindAt[linkUUID] == nil else {
|
||||||
|
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.lastLinkRebindAt[linkUUID] = now
|
||||||
|
|
||||||
|
switch link {
|
||||||
|
case .peripheral(let peripheralUUID):
|
||||||
|
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||||||
|
case .central(let centralUUID):
|
||||||
|
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
||||||
|
}
|
||||||
|
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
self.refreshLocalTopology()
|
||||||
|
// Retire the rotated-away ID only once its last link is gone; a
|
||||||
|
// remaining stale link heals the same way or ages out.
|
||||||
|
guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return }
|
||||||
|
self.messageQueue.async { [weak self] in
|
||||||
|
self?.retireRotatedPeer(previousPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotation is an implicit leave of the old identity: drop it immediately
|
||||||
|
/// instead of letting a ghost duplicate linger for the reachability
|
||||||
|
/// retention window.
|
||||||
|
private func retireRotatedPeer(_ peerID: PeerID) {
|
||||||
|
let removed = collectionsQueue.sync(flags: .barrier) {
|
||||||
|
peerRegistry.remove(peerID) != nil
|
||||||
|
}
|
||||||
|
guard removed else { return }
|
||||||
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
|
refreshLocalTopology()
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||||||
|
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||||
|
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the announce handler environment. All queue hops stay here so
|
/// Builds the announce handler environment. All queue hops stay here so
|
||||||
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
|
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
|
||||||
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
|
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
|
||||||
@@ -4092,7 +4442,8 @@ extension BLEService {
|
|||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
now: now,
|
now: now,
|
||||||
capabilities: announcement.capabilities ?? []
|
capabilities: announcement.capabilities ?? [],
|
||||||
|
bridgeGeohash: announcement.bridgeGeohash
|
||||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
},
|
},
|
||||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||||
@@ -4296,7 +4647,7 @@ extension BLEService {
|
|||||||
/// gossip backfill and hand the payload to the UI layer, where the group
|
/// gossip backfill and hand the payload to the UI layer, where the group
|
||||||
/// coordinator decrypts and authenticates against the roster. Non-members
|
/// coordinator decrypts and authenticates against the roster. Non-members
|
||||||
/// still relay (generic broadcast relay path) but never decode.
|
/// still relay (generic broadcast relay path) but never decode.
|
||||||
private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) {
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let recipient = packet.recipientID else { return true }
|
guard let recipient = packet.recipientID else { return true }
|
||||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||||
@@ -4312,6 +4663,52 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inbound public live-voice packet: broadcast-only, freshness-gated, and
|
||||||
|
/// signature-verified against the claimed sender's announce (mirrors the
|
||||||
|
/// public-message identity gate — `senderID` is attacker-controlled, so a
|
||||||
|
/// valid packet signature is required before any audio reaches the UI).
|
||||||
|
/// Returns whether the packet was accepted; rejected packets must not be
|
||||||
|
/// relayed either, or spoofed 0x29 floods would still amplify.
|
||||||
|
private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
|
guard peerID != myPeerID else { return false }
|
||||||
|
guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false }
|
||||||
|
guard !BLEPacketFreshnessPolicy.isStale(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: Date(),
|
||||||
|
maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds
|
||||||
|
) else { return false }
|
||||||
|
|
||||||
|
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
|
||||||
|
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
|
||||||
|
let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false
|
||||||
|
let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID)
|
||||||
|
guard verifiedViaRegistry || signedDisplayName != nil else {
|
||||||
|
SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: myPeerID,
|
||||||
|
localNickname: myNickname,
|
||||||
|
peers: peersSnapshot,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) ?? signedDisplayName else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = packet.payload
|
||||||
|
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.deliverTransportEvent(.publicVoiceFrameReceived(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: senderNickname,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||||
}
|
}
|
||||||
@@ -4410,8 +4807,6 @@ extension BLEService {
|
|||||||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||||||
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
||||||
}
|
}
|
||||||
// Notify non-UI listeners
|
|
||||||
peerSnapshotSubject.send(transportPeers)
|
|
||||||
// Notify UI on MainActor via delegate
|
// Notify UI on MainActor via delegate
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
||||||
|
|||||||
@@ -19,11 +19,10 @@ 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
|
/// Publishes a bridged kind-1 note (expiring with the board post via
|
||||||
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
||||||
/// was skipped.
|
/// was skipped.
|
||||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String?
|
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String?
|
||||||
/// Requests NIP-09 deletion of a previously bridged note.
|
/// Requests NIP-09 deletion of a previously bridged note.
|
||||||
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
|
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
|
||||||
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
|
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
|
||||||
@@ -35,11 +34,10 @@ final class BoardManager: ObservableObject {
|
|||||||
init(
|
init(
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
store: BoardStore = .shared,
|
store: BoardStore = .shared,
|
||||||
publishToNostr: ((String, String, String, UInt64) -> String?)? = nil,
|
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
|
||||||
deleteFromNostr: ((String, String) -> Void)? = 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
|
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
||||||
cancellable = store.$postsSnapshot
|
cancellable = store.$postsSnapshot
|
||||||
@@ -128,7 +126,7 @@ final class BoardManager: ObservableObject {
|
|||||||
|
|
||||||
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
||||||
// online users see them. Remember the event id for merged deletes.
|
// online users see them. Remember the event id for merged deletes.
|
||||||
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) {
|
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
|
||||||
bridgedEventIDs[postID] = eventID
|
bridgedEventIDs[postID] = eventID
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -160,7 +158,7 @@ final class BoardManager: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? {
|
private static func livePublishToNostr(content: String, geohash: String, nickname: String, 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)
|
||||||
@@ -173,7 +171,8 @@ final class BoardManager: ObservableObject {
|
|||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000)
|
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
|
||||||
|
urgent: urgent
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
return event.id
|
return event.id
|
||||||
|
|||||||
@@ -146,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 {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ struct NoticeItem: Identifiable, Equatable {
|
|||||||
let content: String
|
let content: String
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let isUrgent: Bool
|
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
|
let source: Source
|
||||||
|
|
||||||
var isBoardPost: Bool {
|
var isBoardPost: Bool {
|
||||||
@@ -36,6 +39,9 @@ struct NoticeItem: Identifiable, Equatable {
|
|||||||
content = post.content
|
content = post.content
|
||||||
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
||||||
isUrgent = post.isUrgent
|
isUrgent = post.isUrgent
|
||||||
|
expiresAt = post.expiresAt > 0
|
||||||
|
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
|
||||||
|
: nil
|
||||||
source = .board(post)
|
source = .board(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +52,8 @@ struct NoticeItem: Identifiable, Equatable {
|
|||||||
.first.map(String.init) ?? display
|
.first.map(String.init) ?? display
|
||||||
content = note.content
|
content = note.content
|
||||||
createdAt = note.createdAt
|
createdAt = note.createdAt
|
||||||
isUrgent = false
|
isUrgent = note.isUrgent
|
||||||
|
expiresAt = note.expiresAt
|
||||||
source = .nostr(note)
|
source = .nostr(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,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()
|
||||||
@@ -179,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render on our own timeline what we actually accepted for
|
// Only render on our own timeline what we actually accepted for
|
||||||
@@ -463,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,6 +12,32 @@ 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)"
|
||||||
@@ -255,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)
|
||||||
@@ -305,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] = [
|
||||||
@@ -366,21 +382,6 @@ 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)
|
||||||
@@ -565,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
|
||||||
}
|
}
|
||||||
@@ -70,6 +81,30 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
/// The matched `g` tag: the cell the note was posted to, which can be
|
/// The matched `g` tag: the cell the note was posted to, which can be
|
||||||
/// a neighbor of the subscribed geohash.
|
/// a neighbor of the subscribed geohash.
|
||||||
let geohash: String
|
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))
|
||||||
@@ -90,6 +125,8 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
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)
|
||||||
|
|
||||||
@@ -123,6 +160,34 @@ 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) {
|
||||||
@@ -168,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
|
||||||
@@ -202,10 +269,15 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||||
})?[1].lowercased() else { return }
|
})?[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, geohash: matchedGeohash)
|
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()
|
||||||
@@ -213,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 {
|
||||||
@@ -235,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
|
||||||
@@ -245,7 +356,9 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
geohash: geohash
|
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)
|
||||||
@@ -288,6 +401,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
@@ -297,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// MeshSightingsTracker.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Privacy-preserving daily tally of mesh peers that came within radio range,
|
||||||
|
// so an empty timeline can say "3 devices passed within range today" instead
|
||||||
|
// of feeling dead. Stores only a per-day salted hash per peer plus a count —
|
||||||
|
// no identities, no history beyond today.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MeshSightingsTracker: ObservableObject {
|
||||||
|
static let shared = MeshSightingsTracker()
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let dayKey = "meshSightings.dayKey"
|
||||||
|
static let salt = "meshSightings.salt"
|
||||||
|
static let hashes = "meshSightings.hashes"
|
||||||
|
static let lastSeenAt = "meshSightings.lastSeenAt"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct devices seen within range today (rotating peer IDs may count
|
||||||
|
/// a long-lived neighbor more than once across rotations; that is fine
|
||||||
|
/// for an ambient stat).
|
||||||
|
@Published private(set) var todayCount: Int = 0
|
||||||
|
@Published private(set) var lastSightingAt: Date?
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let now: () -> Date
|
||||||
|
private var seenHashes: Set<String> = []
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.now = now
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordSighting(peerID: PeerID) {
|
||||||
|
rollOverIfNeeded()
|
||||||
|
let hash = saltedHash(peerID.id)
|
||||||
|
let seenAt = now()
|
||||||
|
lastSightingAt = seenAt
|
||||||
|
defaults.set(seenAt, forKey: Keys.lastSeenAt)
|
||||||
|
guard seenHashes.insert(hash).inserted else { return }
|
||||||
|
todayCount = seenHashes.count
|
||||||
|
defaults.set(Array(seenHashes), forKey: Keys.hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
seenHashes.removeAll()
|
||||||
|
todayCount = 0
|
||||||
|
lastSightingAt = nil
|
||||||
|
defaults.removeObject(forKey: Keys.dayKey)
|
||||||
|
defaults.removeObject(forKey: Keys.salt)
|
||||||
|
defaults.removeObject(forKey: Keys.hashes)
|
||||||
|
defaults.removeObject(forKey: Keys.lastSeenAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restore() {
|
||||||
|
rollOverIfNeeded()
|
||||||
|
seenHashes = Set(defaults.stringArray(forKey: Keys.hashes) ?? [])
|
||||||
|
todayCount = seenHashes.count
|
||||||
|
lastSightingAt = defaults.object(forKey: Keys.lastSeenAt) as? Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the tally when the local calendar day changes; the salt rotates
|
||||||
|
/// with it so hashes from different days can never be correlated.
|
||||||
|
private func rollOverIfNeeded() {
|
||||||
|
let today = Self.dayKey(for: now())
|
||||||
|
guard defaults.string(forKey: Keys.dayKey) != today else { return }
|
||||||
|
defaults.set(today, forKey: Keys.dayKey)
|
||||||
|
defaults.set(Self.randomSalt(), forKey: Keys.salt)
|
||||||
|
defaults.removeObject(forKey: Keys.hashes)
|
||||||
|
seenHashes.removeAll()
|
||||||
|
todayCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saltedHash(_ value: String) -> String {
|
||||||
|
let salt = defaults.data(forKey: Keys.salt) ?? {
|
||||||
|
let fresh = Self.randomSalt()
|
||||||
|
defaults.set(fresh, forKey: Keys.salt)
|
||||||
|
return fresh
|
||||||
|
}()
|
||||||
|
var digest = SHA256()
|
||||||
|
digest.update(data: salt)
|
||||||
|
digest.update(data: Data(value.utf8))
|
||||||
|
return digest.finalize().map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dayKey(for date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar.current
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func randomSalt() -> Data {
|
||||||
|
Data((0..<16).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,10 +70,6 @@ final class MessageFormattingEngine {
|
|||||||
static let quickCashuPresence: NSRegularExpression = {
|
static let quickCashuPresence: NSRegularExpression = {
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Match Types
|
// MARK: - Match Types
|
||||||
@@ -195,7 +191,7 @@ final class MessageFormattingEngine {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
|
private static func formatSystemMessage(_ message: BitchatMessage, isDark _: Bool) -> AttributedString {
|
||||||
var result = AttributedString()
|
var result = AttributedString()
|
||||||
|
|
||||||
let content = AttributedString("* \(message.content) *")
|
let content = AttributedString("* \(message.content) *")
|
||||||
@@ -414,7 +410,7 @@ final class MessageFormattingEngine {
|
|||||||
return AttributedString(text).mergingAttributes(style)
|
return AttributedString(text).mergingAttributes(style)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
|
private static func formatMatch(_ text: String, type: MatchType, baseColor _: Color, isSelf _: Bool) -> AttributedString {
|
||||||
var style = AttributeContainer()
|
var style = AttributeContainer()
|
||||||
|
|
||||||
switch type {
|
switch type {
|
||||||
|
|||||||
@@ -52,6 +52,53 @@ final class MessageRouter {
|
|||||||
/// message until an ack arrives.
|
/// message until an ack arrives.
|
||||||
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||||
|
|
||||||
|
/// Parallel deposit into the internet bridge: park a sealed copy on
|
||||||
|
/// relays as a courier drop, so delivery stops requiring a physical
|
||||||
|
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
|
||||||
|
/// (not instead of) mesh couriers; receivers dedup by message ID.
|
||||||
|
/// Returns true when a fresh drop was sealed, so the sender's message
|
||||||
|
/// can show the "carried" state instead of sitting on "sending" forever
|
||||||
|
/// (the delivery ack has no radio route back until the peers next share
|
||||||
|
/// a transport).
|
||||||
|
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
|
||||||
|
|
||||||
|
/// Re-attempts bridge drops for retained messages whose recipient no
|
||||||
|
/// transport can promptly reach anymore. Covers sends that raced the BLE
|
||||||
|
/// reachability retention window: a peer stays "reachable" for a minute
|
||||||
|
/// after its radio disappears, so the original send trusted the mesh and
|
||||||
|
/// skipped the deposit — and nothing else ever retried (field-found).
|
||||||
|
/// Safe to call often: the drop layer dedups by message ID.
|
||||||
|
func retryBridgeCourierDeposits() {
|
||||||
|
guard bridgeCourierDeposit != nil else { return }
|
||||||
|
for (peerID, queue) in outbox {
|
||||||
|
guard let recipientKey = courierDirectory.noiseKey(peerID) else { continue }
|
||||||
|
let promptlyDeliverable = transports.contains {
|
||||||
|
$0.isPeerReachable(peerID) && $0.canDeliverPromptly(to: peerID)
|
||||||
|
}
|
||||||
|
guard !promptlyDeliverable else { continue }
|
||||||
|
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
|
||||||
|
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
|
||||||
|
onMessageCarried?(message.messageID, peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arms the periodic sweep behind `retryBridgeCourierDeposits`. Called
|
||||||
|
/// once by the bootstrapper after the deposit closure is wired; separate
|
||||||
|
/// from init so tests drive the retry directly.
|
||||||
|
func startBridgeDepositSweep(interval: TimeInterval = 120) {
|
||||||
|
bridgeSweepTask?.cancel()
|
||||||
|
bridgeSweepTask = Task { @MainActor [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||||
|
self?.retryBridgeCourierDeposits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bridgeSweepTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
// Outbox limits to prevent unbounded memory growth
|
||||||
@@ -160,6 +207,11 @@ final class MessageRouter {
|
|||||||
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
|
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
|
||||||
guard let recipientKey = courierDirectory.noiseKey(peerID),
|
guard let recipientKey = courierDirectory.noiseKey(peerID),
|
||||||
let entry = queuedMessage(messageID, for: peerID) else { return }
|
let entry = queuedMessage(messageID, for: peerID) else { return }
|
||||||
|
// The bridge drop needs no connected courier — only the recipient
|
||||||
|
// key — so it runs before the courier-slot bookkeeping.
|
||||||
|
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
|
||||||
|
onMessageCarried?(messageID, peerID)
|
||||||
|
}
|
||||||
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||||
guard remainingSlots > 0 else { return }
|
guard remainingSlots > 0 else { return }
|
||||||
|
|
||||||
@@ -311,13 +363,6 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
|
||||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
if let transport = connectedTransport(for: peerID) {
|
if let transport = connectedTransport(for: peerID) {
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
|||||||
@@ -153,11 +153,11 @@ enum EncryptionStatus: Equatable {
|
|||||||
final class NoiseEncryptionService {
|
final class NoiseEncryptionService {
|
||||||
// Static identity key (persistent across sessions)
|
// Static identity key (persistent across sessions)
|
||||||
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
|
let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
|
||||||
|
|
||||||
// Ed25519 signing key (persistent across sessions)
|
// Ed25519 signing key (persistent across sessions)
|
||||||
private let signingKey: Curve25519.Signing.PrivateKey
|
private let signingKey: Curve25519.Signing.PrivateKey
|
||||||
public let signingPublicKey: Curve25519.Signing.PublicKey
|
let signingPublicKey: Curve25519.Signing.PublicKey
|
||||||
|
|
||||||
// Session manager
|
// Session manager
|
||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
|
||||||
/// Emits whether a relay that carries private messages is up
|
/// Emits whether a relay that carries private messages is up
|
||||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||||
/// doesn't count: DM sends target the default relay set and would
|
/// doesn't count: DM sends target the default relay set and would
|
||||||
@@ -42,7 +41,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
self.currentIdentity = currentIdentity
|
self.currentIdentity = currentIdentity
|
||||||
self.registerPendingGiftWrap = registerPendingGiftWrap
|
self.registerPendingGiftWrap = registerPendingGiftWrap
|
||||||
self.sendEvent = sendEvent
|
self.sendEvent = sendEvent
|
||||||
self.scheduleAfter = scheduleAfter
|
|
||||||
self.relayConnectivity = relayConnectivity
|
self.relayConnectivity = relayConnectivity
|
||||||
// Default pacer drives its throttle through the same injected
|
// Default pacer drives its throttle through the same injected
|
||||||
// scheduler, so tests that step scheduleAfter manually keep
|
// scheduler, so tests that step scheduleAfter manually keep
|
||||||
@@ -130,8 +128,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
static let sharedAckPacer = AckPacer()
|
static let sharedAckPacer = AckPacer()
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let idBridge: NostrIdentityBridge
|
|
||||||
private let dependencies: Dependencies
|
private let dependencies: Dependencies
|
||||||
private var favoriteStatusObserver: NSObjectProtocol?
|
private var favoriteStatusObserver: NSObjectProtocol?
|
||||||
|
|
||||||
@@ -145,12 +141,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol,
|
keychain _: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
dependencies: Dependencies? = nil
|
dependencies: Dependencies? = nil
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
|
||||||
self.idBridge = idBridge
|
|
||||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
@@ -207,9 +201,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
weak var eventDelegate: TransportEventDelegate?
|
weak var eventDelegate: TransportEventDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
|
||||||
Just([]).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||||
|
|
||||||
var myPeerID: PeerID { senderPeerID }
|
var myPeerID: PeerID { senderPeerID }
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ protocol NotificationRequestDelivering {
|
|||||||
func add(_ request: UNNotificationRequest)
|
func add(_ request: UNNotificationRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocol NotificationCategoryRegistering {
|
||||||
|
func setCategories(_ categories: Set<UNNotificationCategory>)
|
||||||
|
}
|
||||||
|
|
||||||
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
||||||
private let center: UNUserNotificationCenter
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
@@ -55,6 +59,18 @@ private final class NotificationCenterRequestDelivererAdapter: NotificationReque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(center: UNUserNotificationCenter) {
|
||||||
|
self.center = center
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCategories(_ categories: Set<UNNotificationCategory>) {
|
||||||
|
center.setNotificationCategories(categories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
||||||
func requestAuthorization(
|
func requestAuthorization(
|
||||||
options: UNAuthorizationOptions,
|
options: UNAuthorizationOptions,
|
||||||
@@ -68,12 +84,21 @@ private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
|
|||||||
func add(_ request: UNNotificationRequest) {}
|
func add(_ request: UNNotificationRequest) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering {
|
||||||
|
func setCategories(_ categories: Set<UNNotificationCategory>) {}
|
||||||
|
}
|
||||||
|
|
||||||
final class NotificationService {
|
final class NotificationService {
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
|
/// Category for the "bitchatters nearby" notification, carrying the wave quick action.
|
||||||
|
static let nearbyCategoryID = "chat.bitchat.category.nearby"
|
||||||
|
static let waveActionID = "chat.bitchat.action.wave"
|
||||||
|
|
||||||
private let isRunningTestsProvider: () -> Bool
|
private let isRunningTestsProvider: () -> Bool
|
||||||
private let authorizer: NotificationAuthorizing
|
private let authorizer: NotificationAuthorizing
|
||||||
private let requestDeliverer: NotificationRequestDelivering
|
private let requestDeliverer: NotificationRequestDelivering
|
||||||
|
private let categoryRegistrar: NotificationCategoryRegistering
|
||||||
|
|
||||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||||
private var isRunningTests: Bool {
|
private var isRunningTests: Bool {
|
||||||
@@ -92,25 +117,30 @@ final class NotificationService {
|
|||||||
if isRunningTestsProvider() {
|
if isRunningTestsProvider() {
|
||||||
self.authorizer = NoopNotificationAuthorizer()
|
self.authorizer = NoopNotificationAuthorizer()
|
||||||
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
||||||
|
self.categoryRegistrar = NoopNotificationCategoryRegistrar()
|
||||||
} else {
|
} else {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
||||||
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
||||||
|
self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal init(
|
internal init(
|
||||||
isRunningTestsProvider: @escaping () -> Bool,
|
isRunningTestsProvider: @escaping () -> Bool,
|
||||||
authorizer: NotificationAuthorizing,
|
authorizer: NotificationAuthorizing,
|
||||||
requestDeliverer: NotificationRequestDelivering
|
requestDeliverer: NotificationRequestDelivering,
|
||||||
|
categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar()
|
||||||
) {
|
) {
|
||||||
self.isRunningTestsProvider = isRunningTestsProvider
|
self.isRunningTestsProvider = isRunningTestsProvider
|
||||||
self.authorizer = authorizer
|
self.authorizer = authorizer
|
||||||
self.requestDeliverer = requestDeliverer
|
self.requestDeliverer = requestDeliverer
|
||||||
|
self.categoryRegistrar = categoryRegistrar
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
guard !isRunningTests else { return }
|
guard !isRunningTests else { return }
|
||||||
|
registerCategories()
|
||||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
@@ -120,12 +150,28 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func registerCategories() {
|
||||||
|
let wave = UNNotificationAction(
|
||||||
|
identifier: Self.waveActionID,
|
||||||
|
title: "wave 👋",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
let nearby = UNNotificationCategory(
|
||||||
|
identifier: Self.nearbyCategoryID,
|
||||||
|
actions: [wave],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
categoryRegistrar.setCategories([nearby])
|
||||||
|
}
|
||||||
|
|
||||||
func sendLocalNotification(
|
func sendLocalNotification(
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
identifier: String,
|
identifier: String,
|
||||||
userInfo: [String: Any]? = nil,
|
userInfo: [String: Any]? = nil,
|
||||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
interruptionLevel: UNNotificationInterruptionLevel = .active,
|
||||||
|
categoryIdentifier: String? = nil
|
||||||
) {
|
) {
|
||||||
guard !isRunningTests else { return }
|
guard !isRunningTests else { return }
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
@@ -133,6 +179,9 @@ final class NotificationService {
|
|||||||
content.body = body
|
content.body = body
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.interruptionLevel = interruptionLevel
|
content.interruptionLevel = interruptionLevel
|
||||||
|
if let categoryIdentifier = categoryIdentifier {
|
||||||
|
content.categoryIdentifier = categoryIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
if let userInfo = userInfo {
|
if let userInfo = userInfo {
|
||||||
content.userInfo = userInfo
|
content.userInfo = userInfo
|
||||||
@@ -183,7 +232,8 @@ final class NotificationService {
|
|||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
interruptionLevel: .timeSensitive
|
interruptionLevel: .timeSensitive,
|
||||||
|
categoryIdentifier: Self.nearbyCategoryID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import Foundation
|
|||||||
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
||||||
final class PrekeyBundleStore {
|
final class PrekeyBundleStore {
|
||||||
struct StoredBundle: Codable {
|
struct StoredBundle: Codable {
|
||||||
|
// noiseKey is read in loadFromDisk (dictionary keying), but the
|
||||||
|
// Periphery indexer intermittently misses that read and flakes CI
|
||||||
|
// with "assign-only" — its USR is baselined (an in-source ignore
|
||||||
|
// can't work: strict mode flags it as superfluous on the runs where
|
||||||
|
// the indexer gets it right).
|
||||||
let noiseKey: Data
|
let noiseKey: Data
|
||||||
var generatedAt: UInt64
|
var generatedAt: UInt64
|
||||||
var prekeyIDs: [UInt32]
|
var prekeyIDs: [UInt32]
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
@Published private(set) var selectedPeer: PeerID? = nil
|
@Published private(set) var selectedPeer: PeerID? = nil
|
||||||
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
|
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
|
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
@@ -219,18 +218,13 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
|
|
||||||
/// Start a private chat with a peer. Selection is mutated through the
|
/// Start a private chat with a peer. Selection is mutated through the
|
||||||
/// store's intent (the store owns it); the manager keeps its side
|
/// store's intent (the store owns it); the manager keeps its side
|
||||||
/// effects (fingerprint tracking, read receipts, unread clearing).
|
/// effects (read receipts, unread clearing).
|
||||||
@MainActor
|
@MainActor
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: PeerID) {
|
||||||
// Also creates the conversation if needed and updates the derived
|
// Also creates the conversation if needed and updates the derived
|
||||||
// `selectedConversationID`; `selectedPeer` mirrors the change.
|
// `selectedConversationID`; `selectedPeer` mirrors the change.
|
||||||
conversationStore?.setSelectedPrivatePeer(peerID)
|
conversationStore?.setSelectedPrivatePeer(peerID)
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
|
||||||
selectedPeerFingerprint = fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark messages as read
|
// Mark messages as read
|
||||||
markAsRead(from: peerID)
|
markAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
@@ -239,15 +233,8 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
/// channel's conversation).
|
/// channel's conversation).
|
||||||
func endChat() {
|
func endChat() {
|
||||||
conversationStore?.setSelectedPrivatePeer(nil)
|
conversationStore?.setSelectedPrivatePeer(nil)
|
||||||
selectedPeerFingerprint = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No-op since the `ConversationStore` cutover: the store maintains
|
|
||||||
/// chronological order and dedups by message ID on every insert, so the
|
|
||||||
/// per-append re-sort/dedup sweep this performed is no longer needed.
|
|
||||||
/// Kept only for API compatibility until step 5 removes the callers.
|
|
||||||
func sanitizeChat(for peerID: PeerID) {}
|
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
@MainActor
|
@MainActor
|
||||||
func markAsRead(from peerID: PeerID) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
|
|||||||