Merge remote-tracking branch 'origin/main' into fix/1064-default-view

# Conflicts:
#	bitchat/App/AppRuntime.swift
This commit is contained in:
ecgang 2026-08-02 09:21:17 -07:00
commit 159b688b09
149 changed files with 12362 additions and 3030 deletions

View File

@ -1 +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:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
{"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:7bitchat10BLEServiceC18logBluetoothStatusyySSF","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:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}

View File

@ -26,7 +26,7 @@ check-clean-safety:
check: check-clean-safety
@echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
@developer_dir="$(xcode-select -p 2>/dev/null)"; case "$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
@xcodebuild -version
@echo "✅ Development environment ready (a signing identity is not required for just build)"
@ -35,7 +35,7 @@ build: check
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
run: build
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$app" || (echo "❌ Built app not found at $app" && exit 1); open "$app"
# Backward-compatible alias for the old quick-run recipe.
dev-run: run

View File

@ -8,6 +8,8 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
📲 [Play Store](https://play.google.com/store/apps/details?id=com.bitchat.droid)
### Getting a copy you can trust
Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get.
@ -49,7 +51,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **440+ Relay Network**: Distributed across the globe for reliability
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area

View File

@ -94,7 +94,6 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
bitchatShareExtension.entitlements,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
@ -379,6 +378,11 @@
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
C5E027A42ECCDFD700BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
@ -395,13 +399,6 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */

View File

@ -94,7 +94,8 @@ final class AppChromeModel: ObservableObject {
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
guard let diagnostics = mesh as? MeshDiagnosing,
let snapshot = diagnostics.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in

View File

@ -152,7 +152,7 @@ final class AppRuntime: ObservableObject {
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
expireAgedMedia()
performMediaMaintenance()
restoreLastActiveConversationOnLaunch()
record(.launched)
@ -370,12 +370,17 @@ final class AppRuntime: ObservableObject {
}
}
/// Drops media that has outlived the retention window. Off the main thread
/// and best-effort: the sweep walks the media tree, and nothing at launch
/// depends on its result.
private func expireAgedMedia() {
Task(priority: .utility) {
BLEIncomingFileStore().expireAgedMedia()
/// Drops media that has outlived the retention window, then applies the
/// explicit protection class to files that older builds wrote without
/// one. Expiry runs first so the migration never touches files the
/// sweep is about to delete. Detached because `AppRuntime` is
/// main-actor and both passes go file by file through the media tree;
/// best-effort, nothing at launch depends on their results.
private func performMediaMaintenance() {
Task.detached(priority: .utility) {
let store = BLEIncomingFileStore()
store.expireAgedMedia()
store.migrateFileProtectionIfNeeded()
}
}

View File

@ -230,8 +230,7 @@ final class Conversation: ObservableObject, Identifiable {
// MARK: Internals
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
guard let current else { return false }
static func shouldSkipStatusUpdate(current: DeliveryStatus, new: DeliveryStatus) -> Bool {
if current == new { return true }
// Never downgrade to a weaker delivery state. Ordering of certainty:
@ -254,6 +253,10 @@ final class Conversation: ObservableObject, Identifiable {
return true
case (.sent, .sending):
return true
case (_, .notSentYet):
// .notSentYet is the pre-transport initial state; once a message
// has any real status, resetting to it is always a downgrade.
return true
default:
return false
}

View File

@ -9,6 +9,7 @@ import UIKit
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var selectedAutocompleteIndex = 0
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject {
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
@ -104,6 +106,31 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text)
}
/// Accept the currently highlighted mention suggestion, if any.
func completeSelectedSuggestion(in text: inout String) -> Bool {
guard showAutocomplete,
autocompleteSuggestions.indices.contains(selectedAutocompleteIndex)
else { return false }
_ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text)
return true
}
/// Dismiss the mention suggestion panel without inserting (Escape).
func dismissAutocomplete() {
guard showAutocomplete else { return }
chatViewModel.showAutocomplete = false
chatViewModel.autocompleteSuggestions = []
chatViewModel.autocompleteRange = nil
chatViewModel.selectedAutocompleteIndex = 0
}
func moveAutocompleteSelection(by delta: Int) {
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
let count = min(4, autocompleteSuggestions.count)
let next = (selectedAutocompleteIndex + delta + count) % count
chatViewModel.selectedAutocompleteIndex = next
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
@ -128,6 +155,18 @@ final class ConversationUIModel: ObservableObject {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
/// Whether a private-message row should show the filled verification seal
/// next to the sender name (#1439). Scoped to DMs only public timelines
/// have different trust semantics and stay undressed.
func showsVerifiedSeal(for message: BitchatMessage) -> Bool {
guard message.isPrivate,
message.sender != "system",
!isSentByCurrentUser(message),
let peerID = message.senderPeerID else { return false }
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return chatViewModel.peerIdentityStore.isVerified(fingerprint)
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
@ -193,6 +232,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$selectedAutocompleteIndex
.receive(on: DispatchQueue.main)
.assign(to: &$selectedAutocompleteIndex)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
@ -219,6 +262,15 @@ final class ConversationUIModel: ObservableObject {
self?.refreshComputedState()
}
.store(in: &cancellables)
// Verify/unverify while a DM is open must repaint existing rows
// showsVerifiedSeal is computed per render, so forward the store change.
chatViewModel.peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func refreshComputedState() {

View File

@ -36,6 +36,7 @@ final class LocationPresenceStore: ObservableObject {
return
}
let nickname = nickname.normalizedNickname
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
@ -64,7 +65,7 @@ final class LocationPresenceStore: ObservableObject {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
normalized[lower] = value.normalizedNickname
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))

View File

@ -213,7 +213,7 @@ final class PeerListModel: ObservableObject {
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.nickname,
displayName: isMe ? chatViewModel.nickname : peer.displayName,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
@ -248,7 +248,7 @@ final class PeerListModel: ObservableObject {
self.groupRows = groupRows
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"

View File

@ -294,6 +294,21 @@ final class PrivateConversationModel: ObservableObject {
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
}
// Local alias wins over a live peer row's announced nickname.
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint),
let pet = social.localPetname, !pet.isEmpty {
return pet
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint),
let pet = social.localPetname, !pet.isEmpty {
return pet
}
}
if let displayName = peer?.displayName {
return displayName
}
@ -308,23 +323,15 @@ final class PrivateConversationModel: ObservableObject {
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint),
!social.claimedNickname.isEmpty {
return social.claimedNickname
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint),
!social.claimedNickname.isEmpty {
return social.claimedNickname
}
}

View File

@ -8,6 +8,9 @@ struct FingerprintPresentationState: Equatable {
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
/// User-assigned local alias (petname), if any distinct from the
/// peer-claimed nickname.
let localPetname: String?
/// Number of currently-valid vouches from peers the user verified
/// (0 when the peer is explicitly verified the stronger badge wins).
let voucherCount: Int
@ -20,6 +23,11 @@ struct FingerprintPresentationState: Equatable {
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
}
/// Alias field is editable once we know who we're looking at.
var canEditLocalAlias: Bool {
theirFingerprint != nil
}
}
enum VerificationScanOutcome: Equatable {
@ -75,6 +83,46 @@ final class VerificationModel: ObservableObject {
chatViewModel.unverifyFingerprint(for: peerID)
}
/// Persist a local alias for this peer. Empty/whitespace clears it so the
/// claimed nickname shows again. Display paths prefer `localPetname`
/// when set (#1439).
func setLocalPetname(_ petname: String?, for peerID: PeerID) {
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
guard let fingerprint = chatViewModel.getFingerprint(for: statusPeerID) else { return }
let trimmed = petname?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized: String? = (trimmed?.isEmpty == false) ? trimmed : nil
let existing = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)
let claimed = existing?.claimedNickname
?? chatViewModel.meshService.peerNickname(peerID: statusPeerID)
?? chatViewModel.resolveNickname(for: statusPeerID)
var identity = existing ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: claimed,
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
identity.localPetname = normalized
// Prefer the mesh-announced name for claimedNickname so we don't
// persist a previous alias as the "claimed" identity.
if let announced = chatViewModel.meshService.peerNickname(peerID: statusPeerID),
!announced.isEmpty {
identity.claimedNickname = announced
} else if identity.claimedNickname.isEmpty {
identity.claimedNickname = claimed
}
chatViewModel.identityManager.updateSocialIdentity(identity)
// Rebuild peer rows so PeerList / DM header pick up the new display name
// without waiting for an unrelated mesh event.
chatViewModel.unifiedPeerService.refreshPeers()
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
objectWillChange.send()
}
func isVerified(peerID: PeerID) -> Bool {
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return peerIdentityStore.isVerified(fingerprint)
@ -86,6 +134,8 @@ final class VerificationModel: ObservableObject {
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let localPetname = theirFingerprint
.flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname }
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
@ -110,6 +160,7 @@ final class VerificationModel: ObservableObject {
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: isVerified,
localPetname: localPetname,
voucherCount: vouchers.count,
voucherNames: voucherNames
)
@ -158,6 +209,15 @@ final class VerificationModel: ObservableObject {
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
// Prefer an explicit local alias even when a live peer row exists
// peer.displayName already does this once UnifiedPeerService rebuilds,
// but read social identity directly so the fingerprint sheet header
// updates before that rebuild lands.
if let fingerprint = chatViewModel.getFingerprint(for: statusPeerID),
let pet = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)?.localPetname,
!pet.isEmpty {
return pet
}
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
return peer.displayName
}
@ -171,9 +231,6 @@ final class VerificationModel: ObservableObject {
}
let fingerprint = data.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}

View File

@ -206,7 +206,7 @@ enum ImageUtils {
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return directory.appendingPathComponent(fileName)
}

View File

@ -244,7 +244,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}

View File

@ -300,7 +300,7 @@ actor VoiceRecorder {
let baseDirectory = try outputDirectory
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return baseDirectory.appendingPathComponent(fileName)
}

View File

@ -663,7 +663,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
_ = self.ephemeralSessions.removeValue(forKey: peerID)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ extension BitchatMessage {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
self.filesDir = filesDir
} catch {
filesDir = nil

View File

@ -15,6 +15,10 @@ struct BitchatPeer: Equatable {
// Nostr identity (if known)
var nostrPublicKey: String?
/// Device-local alias (petname). Never sent over the wire; when set it
/// outranks the peer-claimed `nickname` for display only.
var localPetname: String?
// Connection state
enum ConnectionState {
@ -51,7 +55,10 @@ struct BitchatPeer: Equatable {
// Display helpers
var displayName: String {
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
if let localPetname, !localPetname.isEmpty {
return localPetname
}
return nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
}
var statusIcon: String {
@ -78,13 +85,15 @@ struct BitchatPeer: Equatable {
nickname: String,
lastSeen _: Date = Date(),
isConnected: Bool = false,
isReachable: Bool = false
isReachable: Bool = false,
localPetname: String? = nil
) {
self.peerID = peerID
self.noisePublicKey = noisePublicKey
self.nickname = nickname
self.isConnected = isConnected
self.isReachable = isReachable
self.localPetname = localPetname
// Load favorite status - will be set later by the manager
self.favoriteStatus = nil

View File

@ -1028,6 +1028,27 @@ final class NoiseSessionManager {
.cancel()
}
#if DEBUG
/// Fires a pending suppressed-initiation recovery immediately instead of
/// waiting out the completion-grace timer, so tests can inject a grace
/// period too large to lose against a starved runner and still exercise
/// the recovery path deterministically.
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
guard let pending = suppressedInitiationRecoveryTimeouts
.removeValue(forKey: peerID) else {
return
}
pending.cancel()
guard let current = sessions[peerID],
current.isEstablished() else {
return
}
requestHandshakeRecovery(for: peerID)
}
}
#endif
private func requestHandshakeRecovery(
for peerID: PeerID,
after delay: TimeInterval = 0

View File

@ -153,14 +153,18 @@ final class NostrRelayManager: ObservableObject {
// Built-in relays carry private-message envelopes, so avoid relays known to
// reject the kinds they use.
private static let builtInRelays = [
nonisolated private static let builtInRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://offchain.pub"
// For local testing, you can add: "ws://localhost:8080"
]
private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) })
/// Exposed so the relay settings UI can reject re-adding a built-in.
/// `nonisolated` because it is an immutable constant with no actor state.
nonisolated static let builtInRelayURLs = Set(
builtInRelays.compactMap { NostrRelayURL.normalized($0) }
)
/// The relays private messages target: the built-in set plus any added by
/// hand. Four hardcoded hostnames are four names for a censor to block, so
@ -182,10 +186,6 @@ final class NostrRelayManager: ObservableObject {
defaultRelaySet = Set(defaultRelays)
}
/// Exposed so the relay settings UI can reject re-adding a built-in.
/// `nonisolated` because it is an immutable constant with no actor state.
nonisolated static var builtInRelayURLs: Set<String> { builtInRelaySet }
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs

View File

@ -55,10 +55,10 @@ final class AutocompleteService {
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased()
let suggestions = peers
.filter { $0.lowercased().hasPrefix(prefix) }
.filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) }
.sorted()
.prefix(5)
.map { "@\($0)" }

View File

@ -37,4 +37,13 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
return true
}
}
/// Forgets the last-sent timestamp. A panic rotation calls this so the
/// new identity's first announce cannot be swallowed by the old
/// identity's throttle debt otherwise a panic within the forced
/// minimum interval of the last announce leaves the rotated identity
/// invisible until the next maintenance cycle.
func reset() {
lock.withLock { lastSent = .distantPast }
}
}

View File

@ -0,0 +1,39 @@
import Foundation
/// Schedules deferred engine work: relay jitter, announce delays, protocol
/// deadlines (ping, capability proof), notification retry backoff, and
/// fragment pacing.
///
/// This is the transport's only source of engine-side delay. Production
/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced
/// clock so timer-driven behavior is asserted deterministically instead of
/// racing the wall clock product constants used as deadlines are exactly
/// the hidden-elapsed-deadline flake class the test-timing hygiene rules
/// exist to contain.
protocol BLEEngineScheduling: AnyObject {
/// Called once by the transport with its engine queue. Scheduled work
/// always executes there: deferred bodies touch engine-confined state.
func activate(engineQueue: DispatchQueue)
/// Runs `work` on the engine queue after `delay`, honoring
/// `DispatchWorkItem` cancellation.
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem)
}
extension BLEEngineScheduling {
func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) {
schedule(after: delay, execute: DispatchWorkItem(block: body))
}
}
/// Production scheduler: a thin veneer over the engine queue.
final class BLEEngineDispatchScheduler: BLEEngineScheduling {
private var queue: DispatchQueue?
func activate(engineQueue: DispatchQueue) {
queue = engineQueue
}
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
queue?.asyncAfter(deadline: .now() + delay, execute: work)
}
}

View File

@ -140,6 +140,20 @@ struct BLEIncomingFileStore: @unchecked Sendable {
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Media payloads follow the same at-rest posture as the app's other
/// persistence layers (courier, outbox, receipt index): protected until
/// first unlock, so the launch-time retention sweep can still run after
/// a reboot. Applied to the media directories so recordings that save
/// as they go (live captures, `AVAudioRecorder`) inherit it, and stated
/// explicitly at the payload write site like every other store.
static var mediaProtectionAttributes: [FileAttributeKey: Any]? {
#if os(iOS)
return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
#else
return nil
#endif
}
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
@ -223,7 +237,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
attributes: Self.mediaProtectionAttributes
)
}
} catch {
@ -268,7 +282,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
return directory
}
@ -284,7 +298,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
let sanitized = sanitizedFileName(
preferredName,
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
@ -306,7 +320,11 @@ struct BLEIncomingFileStore: @unchecked Sendable {
),
forceRandomizedName: reservedPaths == nil
)
try data.write(to: destination, options: .atomic)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: destination, options: options)
payloadCoordination.pendingDeliveryPaths.insert(
destination.standardizedFileURL.path
)
@ -650,9 +668,90 @@ struct BLEIncomingFileStore: @unchecked Sendable {
return removed
}
/// Stamps the media directories and any resident payloads with the
/// explicit protection class, covering files written by builds that
/// relied on the container default. Runs every launch: re-stamping an
/// equal class is a metadata no-op, and anything carrying a stronger
/// class is left alone, so repetition is cheap and can never downgrade.
/// In-flight live captures are skipped for symmetry with the retention
/// sweep; they receive the class at creation and need no repair.
/// Best-effort like the sweep it runs alongside; a file that cannot be
/// stamped is logged, not fatal, and the migration moves on to the next
/// item. Returns the number of items stamped so the launch path and
/// tests can observe coverage.
@discardableResult
func migrateFileProtectionIfNeeded() -> Int {
#if os(iOS)
guard let attributes = Self.mediaProtectionAttributes else { return 0 }
var stamped = 0
guard let base = try? filesDirectory() else { return 0 }
for subdirectory in Self.mediaSubdirectories {
let dir = base.appendingPathComponent(subdirectory, isDirectory: true)
guard fileManager.fileExists(atPath: dir.path) else { continue }
let files = (try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey],
options: [.skipsHiddenFiles]
)) ?? []
stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes)
for fileURL in files {
guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes)
}
}
return stamped
#else
return 0
#endif
}
#if os(iOS)
/// Applies the class to one item, but only when the item currently sits
/// at the container default or weaker. The list names the classes that
/// are safe to replace; anything else, including classes added in later
/// iOS versions, is left alone. Only regular files are stamped when
/// `requireRegularFile` is set (and only real directories otherwise),
/// matching the caution the legacy-file removal path applies; symlinks
/// and other non-regular files are left untouched.
private func stampProtectionIfWeaker(
_ itemURL: URL,
requireRegularFile: Bool,
attributes: [FileAttributeKey: Any]
) -> Int {
let values = try? itemURL.resourceValues(
forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey]
)
if requireRegularFile {
guard values?.isRegularFile == true else { return 0 }
} else {
guard values?.isDirectory == true else { return 0 }
}
if let current = values?.fileProtection,
current != .none,
current != .completeUntilFirstUserAuthentication {
return 0
}
do {
try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path)
return 1
} catch let error as CocoaError where error.code == .fileNoSuchFile {
// Quota eviction or a deletion commit on another store instance
// can delete an item out from under this migration; that is not
// a failure.
return 0
} catch {
SecureLogger.warning(
"⚠️ Failed to migrate media file protection: \(error)",
category: .security
)
return 0
}
}
#endif
private func filesDirectory() throws -> URL {
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
return filesDir
}

View File

@ -124,3 +124,45 @@ struct BLEIngressLinkRegistry {
packet.isRSR && packet.ttl == 0
}
}
/// Lock-backed shared ownership of the ingress-link registry. Ingress is
/// recorded on bleQueue the moment a frame decodes (the link identity is
/// only known there, and the duplicate-ingress gate must answer before
/// the packet is handed to the engine), while relay and routing decisions
/// read it from the engine. Every registry mutation is a single
/// whole-transition method, so readers never observe a torn state.
final class BLEIngressLinkStore: @unchecked Sendable {
private let lock = NSLock()
private var registry = BLEIngressLinkRegistry()
var isEmpty: Bool {
lock.withLock { registry.isEmpty }
}
func removeAll() {
lock.withLock { registry.removeAll() }
}
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
lock.withLock { registry.record(for: packet) }
}
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
lock.withLock { registry.link(for: packet) }
}
func recordIfNew(
_ packet: BitchatPacket,
link: BLEIngressLinkID,
peerID: PeerID,
lifetime: TimeInterval
) -> Bool {
lock.withLock {
registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime)
}
}
func prune(before cutoff: Date) {
lock.withLock { registry.prune(before: cutoff) }
}
}

View File

@ -0,0 +1,115 @@
import BitFoundation
import Foundation
/// Per-link Noise authentication and rebind-containment state.
///
/// A peer ID can retain an established Noise session after its physical
/// link disappears, and link bindings heal on announces whose directness
/// is forgeable (TTL is unsigned). This state pins the stronger facts the
/// containment rules need: which exact ingress link a Noise handshake
/// completed on, each link's revalidation epoch, and the cooldowns that
/// stop a replayed announce from flip-flopping bindings or survivor
/// selection.
///
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
/// alongside the link bindings it qualifies: BLEService debug-traps any
/// access off the engine queue.
struct BLELinkAuthState {
private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:]
private var reconnectPolicy = BLENoiseReconnectPolicy()
// Entries older than the cooldown are pruned on each check.
private var lastRebindAt: [String: Date] = [:]
private var lastRedundantRetirementAt: [PeerID: Date] = [:]
// MARK: - Authentication ownership
/// Whether `peerID`'s Noise session was established on this exact link.
func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool {
authenticatedOwners[link] == peerID
}
func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
authenticatedOwners.compactMap { link, owner in
owner == peerID ? link : nil
}
}
mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) {
authenticatedOwners[link] = peerID
}
/// Retires a link's proof and closes its revalidation epoch the pair
/// every teardown path (disconnect, unsubscribe, timeout, rebind,
/// redundant retirement) must apply together.
mutating func retireLink(_ link: BLEIngressLinkID) {
authenticatedOwners.removeValue(forKey: link)
reconnectPolicy.endLinkEpoch(link)
}
/// Retires every link the departing peer's proofs still own; returns
/// the retired links.
mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
let departed = links(ownedBy: peerID)
for link in departed {
retireLink(link)
}
return departed
}
/// Drops every link proof and revalidation epoch. The containment
/// cooldowns deliberately SURVIVE this: panic and emergency resets can
/// restart services well inside `bleLinkRebindCooldownSeconds`, and a
/// stable CoreBluetooth UUID must not get a fresh rebind/retirement
/// allowance just because the session state around it was wiped. The
/// maps stay time-pruned on each permit check.
mutating func removeAll() {
authenticatedOwners.removeAll()
reconnectPolicy.removeAll()
}
// MARK: - Session revalidation
/// Whether a fresh direct link warrants revalidating a cached
/// peer-level session with a new XX exchange.
mutating func shouldRevalidate(
on link: BLEIngressLinkID,
for peerID: PeerID,
hasEstablishedSession: Bool,
hasAuthenticatedPeerLink: Bool,
now: Date
) -> Bool {
reconnectPolicy.shouldRevalidate(
on: link,
hasEstablishedSession: hasEstablishedSession,
isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID),
hasAuthenticatedPeerLink: hasAuthenticatedPeerLink,
now: now
)
}
// MARK: - Rebind containment cooldowns
/// At most one rotation rebind per link per cooldown window, so two
/// identities can't fight over a link in a replay flip-flop. Prunes,
/// checks, and records in one transition; true = permitted (recorded).
mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool {
lastRebindAt = lastRebindAt.filter {
now.timeIntervalSince($0.value) < cooldown
}
guard lastRebindAt[linkUUID] == nil else { return false }
lastRebindAt[linkUUID] = now
return true
}
/// At most one redundant-link retirement per peer per cooldown window,
/// bounding how often a replayed announce could flip which duplicate
/// link survives. True = permitted (recorded).
mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool {
lastRedundantRetirementAt = lastRedundantRetirementAt.filter {
now.timeIntervalSince($0.value) < cooldown
}
guard lastRedundantRetirementAt[peerID] == nil else { return false }
lastRedundantRetirementAt[peerID] = now
return true
}
}

View File

@ -0,0 +1,152 @@
import BitFoundation
import Foundation
/// Identitylink bindings: which peer each physical link currently
/// belongs to, in both roles, plus each peer's preferred peripheral link
/// for directed sends and fanout collapse.
///
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine,
/// *what links exist* stays on bleQueue in the physical store. BLEService
/// debug-traps any access off the engine queue.
///
/// Lifecycle contract: bindings are only created for live physical links
/// (callers check liveness through `readLinkState`) and are retired
/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop
/// queued by the physical teardown. A binding can therefore briefly
/// outlive its departed link; queries that need liveness join against the
/// physical store, and everything converges once the queued retirement
/// runs.
struct BLELinkBindings {
private var peripheralPeers: [String: PeerID] = [:]
private var centralPeers: [String: PeerID] = [:]
/// The peer's most recently bound peripheral link, kept so duplicate-
/// link fanout collapse stays deterministic (see BLEFanoutSelector).
private var preferredPeripheral: [PeerID: String] = [:]
// MARK: - Queries
func peer(forPeripheralID peripheralID: String) -> PeerID? {
peripheralPeers[peripheralID]
}
func peer(forCentralUUID centralUUID: String) -> PeerID? {
centralPeers[centralUUID]
}
func boundPeer(for link: BLEIngressLinkID) -> PeerID? {
switch link {
case .peripheral(let peripheralUUID):
return peripheralPeers[peripheralUUID]
case .central(let centralUUID):
return centralPeers[centralUUID]
}
}
/// Every link bound to the peer, both roles. After a state restoration
/// the same device can hold several live peripheral links bound to one
/// peer (it reappears under a fresh UUID while the restored connection
/// lives on), so this scans all bindings rather than the 1:1 preferred
/// map.
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID {
links.insert(.central(centralUUID))
}
return links
}
func hasCentral(boundTo peerID: PeerID) -> Bool {
centralPeers.values.contains(peerID)
}
func preferredPeripheralUUID(for peerID: PeerID) -> String? {
preferredPeripheral[peerID]
}
/// The full preferred-peripheral map, for fanout collapse.
var preferredPeripheralBindings: [PeerID: String] {
preferredPeripheral
}
/// The full central binding map, for the subscribed-central snapshot.
var centralPeersByUUID: [String: PeerID] {
centralPeers
}
// MARK: - Binding transitions
mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) {
centralPeers[centralUUID] = peerID
}
mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
let previousPeerID = peripheralPeers[peripheralUUID]
peripheralPeers[peripheralUUID] = peerID
// 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,
preferredPeripheral[previousPeerID] == peripheralUUID {
preferredPeripheral.removeValue(forKey: previousPeerID)
}
preferredPeripheral[peerID] = peripheralUUID
}
/// Retires a peripheral link's binding. When the removed link was the
/// peer's preferred one, the reverse map is repaired onto a surviving
/// duplicate chosen by the caller from the peer's remaining bound links
/// (the caller knows physical liveness; prefer a writable survivor
/// repairing onto a link mid-service-rediscovery would strand directed
/// sends until its characteristic comes back).
mutating func peripheralRemoved(
_ peripheralUUID: String,
chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String?
) -> PeerID? {
guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else {
return nil
}
// Only clear (or repair) the reverse map when it points at the
// removed link: with duplicate links to one peer, removing a stale
// duplicate must not strand the peer's surviving bound link.
if preferredPeripheral[peerID] == peripheralUUID {
let remaining = peripheralPeers.compactMap { uuid, boundPeer in
boundPeer == peerID ? uuid : nil
}
if let survivorUUID = chooseSurvivor(remaining) {
preferredPeripheral[peerID] = survivorUUID
} else {
preferredPeripheral.removeValue(forKey: peerID)
}
}
return peerID
}
mutating func centralRemoved(_ centralUUID: String) -> PeerID? {
centralPeers.removeValue(forKey: centralUUID)
}
/// Drops every peripheral binding; returns the peers that held one.
mutating func clearPeripherals() -> [PeerID] {
let peerIDs = Array(peripheralPeers.values)
peripheralPeers.removeAll()
preferredPeripheral.removeAll()
return peerIDs
}
/// Drops every central binding; returns the peers that held one.
mutating func clearCentrals() -> [PeerID] {
let peerIDs = Array(centralPeers.values)
centralPeers.removeAll()
return peerIDs
}
mutating func removeAll() {
peripheralPeers.removeAll()
centralPeers.removeAll()
preferredPeripheral.removeAll()
}
}

View File

@ -0,0 +1,40 @@
import BitFoundation
import Foundation
/// The upward half of the link-layer port: everything the bleQueue link
/// layer tells the engine, as one enumerable surface with one engine
/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates
/// shrink to physical bookkeeping plus event emission, and the simulated
/// mesh drives the engine through exactly the same seam.
///
/// Naming follows the physical stores: a *peripheral link* is a
/// connection we own as central (keyed by the remote peripheral's UUID);
/// a *central link* is a remote central subscribed to our peripheral role
/// (keyed by its UUID).
enum BLELinkEvent {
/// A decoded frame arrived on a link. Attribution binding lookup,
/// spoof rejection, raw-announce binding, ingress recording is
/// engine work. Emission captures the panic lifecycle at the handoff.
case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String)
/// One peripheral link ended (disconnect, connect failure, or radio
/// policy teardown). The engine retires the link's identity half
/// proof, epoch, binding with survivor repair and, when
/// `runPeerBookkeeping` is set (real disconnects), marks the peer
/// disconnected once its last live link is gone and republishes the
/// peer list.
case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool)
/// A remote central unsubscribed. The engine retires the central
/// link's identity half and runs last-link peer bookkeeping.
case centralLinkEnded(centralUUID: String)
/// The central role reset and every peripheral link is gone
/// (power-off retires proofs and notifies peers; an authorization
/// loss only drops the bindings).
case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool)
/// The peripheral role reset and every central link is gone (same
/// power-off / authorization-loss split).
case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool)
}

View File

@ -5,10 +5,15 @@ import Foundation
struct BLEPeripheralLinkState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
/// When didConnect last fired for this link. Nil for links restored
/// already-connected (their connect predates this process), which is
/// exactly the signal redundant-link consolidation needs: a restored
/// link lives on an old BLE address the peer no longer advertises,
/// so it must never be kept over a freshly connected duplicate.
var lastConnectedAt: Date? = nil
var assembler: NotificationStreamAssembler
}
@ -26,17 +31,20 @@ struct BLESubscribedCentralSnapshot {
}
}
/// Owns all BLE link state (peripheral connections we hold as central, and
/// central subscriptions we serve as peripheral). The store has no internal
/// locking: every access must happen on the single owning queue (the BLE
/// queue). Other queues must go through BLEService's `readLinkState`, which
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
/// any access from the wrong queue.
// BLEDirectLinkState and the identitylink binding queries live on
// BLELinkBindings; this store owns only physical link state.
/// Owns the PHYSICAL BLE link state (peripheral connections we hold as
/// central, and central subscriptions we serve as peripheral) CB object
/// handles, connect lifecycles, characteristics, and stream assemblers.
/// Identitylink bindings live on `BLELinkBindings`. The store has no
/// internal locking: every access must happen on the single owning queue
/// (the BLE queue). Other queues must go through BLEService's
/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)`
/// to have debug builds trap any access from the wrong queue.
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
#if DEBUG
private var ownerQueue: DispatchQueue?
@ -64,14 +72,6 @@ final class BLELinkStateStore {
return Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
assertOwned()
return BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
assertOwned()
return subscribedCentrals.count
@ -109,7 +109,6 @@ final class BLELinkStateStore {
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: date,
@ -119,20 +118,21 @@ final class BLELinkStateStore {
)
}
func markConnected(_ peripheral: CBPeripheral) {
func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
$0.lastConnectedAt = now
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
lastConnectedAt: now,
assembler: NotificationStreamAssembler()
),
for: peripheralID
@ -146,130 +146,35 @@ final class BLELinkStateStore {
}
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
assertOwned()
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
assertOwned()
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
assertOwned()
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
// Scan all states rather than the 1:1 reverse map: after a state
// restoration the same device can hold several live peripheral links
// bound to one peer (it reappears under a fresh UUID while the
// restored connection lives on).
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
/// The peer's most recently bound peripheral link, per peer. Used to keep
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
var preferredPeripheralBindings: [PeerID: String] {
assertOwned()
return peerToPeripheralUUID
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
assertOwned()
return peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
assertOwned()
return centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
assertOwned()
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
func removeSubscribedCentral(_ central: CBCentral) {
assertOwned()
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
func removePeripheral(_ peripheralID: String) {
assertOwned()
centralToPeerID[centralUUID] = peerID
peripherals.removeValue(forKey: peripheralID)
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
func clearPeripherals() {
assertOwned()
var previousPeerID: PeerID?
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? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
// Only clear (or repair) the reverse map when it points at the removed
// link: with duplicate links to one peer, removing a stale duplicate
// must not strand the peer's surviving bound link.
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
// Prefer a writable survivor: repairing onto a link that is
// mid-service-rediscovery would strand directed sends until the
// characteristic comes back.
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
peerToPeripheralUUID[peerID] = survivorUUID
} else {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
}
return peerID
}
func clearPeripherals() -> [PeerID] {
assertOwned()
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
return peerIDs
}
func clearCentrals() -> [PeerID] {
func clearCentrals() {
assertOwned()
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
return peerIDs
}
func clearAll() {
assertOwned()
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
}
}

View File

@ -5,6 +5,20 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
let peerID: PeerID
let peerIDData: Data
let nickname: String
/// Runtime-toggled capability bits (e.g. the internet-gateway toggle)
/// ORed into `PeerCapabilities.localSupported` for every announce.
let runtimeCapabilities: PeerCapabilities
/// Rendezvous cell advertised while bridging; rides announces only
/// while the `.bridge` capability is enabled.
let bridgeGeohash: String?
var advertisedCapabilities: PeerCapabilities {
PeerCapabilities.localSupported.union(runtimeCapabilities)
}
var advertisedBridgeGeohash: String? {
runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil
}
}
/// Lock-backed local identity state shared by the transport's message,
@ -12,8 +26,8 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
///
/// `peerID` and its binary wire representation must change as one unit during
/// panic rotation. A snapshot also gives announce construction one consistent
/// view of the nickname and identity instead of reading three independently
/// mutable properties across queues.
/// view of the nickname, identity, and advertised capabilities instead of
/// reading independently mutable properties across queues.
final class BLELocalIdentityStateStore: @unchecked Sendable {
private let lock = NSLock()
private var state: BLELocalIdentitySnapshot
@ -25,7 +39,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: nickname
nickname: nickname,
runtimeCapabilities: [],
bridgeGeohash: nil
)
}
@ -38,7 +54,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: nickname
nickname: nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: state.bridgeGeohash
)
}
}
@ -48,8 +66,48 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: state.nickname
nickname: state.nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: state.bridgeGeohash
)
}
}
/// Flips a runtime capability bit. Returns whether anything changed.
@discardableResult
func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool {
lock.withLock {
var capabilities = state.runtimeCapabilities
if enabled {
capabilities.insert(capability)
} else {
capabilities.remove(capability)
}
guard capabilities != state.runtimeCapabilities else { return false }
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: state.nickname,
runtimeCapabilities: capabilities,
bridgeGeohash: state.bridgeGeohash
)
return true
}
}
/// Sets the bridged rendezvous cell. Returns whether anything changed.
@discardableResult
func setBridgeGeohash(_ cell: String?) -> Bool {
lock.withLock {
guard cell != state.bridgeGeohash else { return false }
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: state.nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: cell
)
return true
}
}
}

View File

@ -0,0 +1,62 @@
import BitFoundation
import Foundation
struct BLEMeshPingProbe {
let peerID: PeerID
let sentAt: Date
let lifecycleGeneration: UInt64
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
/// Engine-confined /ping diagnostics state: outstanding probes keyed by
/// their unguessable nonce, plus the inbound response budget.
///
/// The budget is keyed by the ingress link (the directly connected peer
/// that delivered the packet), never the packet-claimed sender: pings are
/// unsigned, so the claimed sender is attacker-controlled and rotating it
/// would reset the budget, turning a directed unencrypted probe into an
/// amplification primitive.
///
/// Pure state the transport owns packet I/O, timers, and main-actor
/// completion delivery around it.
struct BLEMeshPingTracker {
private var pendingProbes: [Data: BLEMeshPingProbe] = [:]
private var responseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) {
pendingProbes[nonce] = probe
}
/// Resolves a pong against its outstanding probe. The echoed nonce plus
/// the sender check bind the reply to the probed peer.
mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? {
guard pendingProbes[nonce]?.peerID == peerID else { return nil }
return pendingProbes.removeValue(forKey: nonce)
}
/// Removes a timed-out probe so its completion can fire once with nil.
mutating func expire(nonce: Data) -> BLEMeshPingProbe? {
pendingProbes.removeValue(forKey: nonce)
}
/// Whether an inbound ping delivered by this link is within budget.
mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool {
responseLimiter.shouldRespond(to: linkPeerID, now: now)
}
/// Drops all probes and restores a fresh response budget (panic wipe).
/// Returns the orphaned timeout work items for the caller to cancel.
mutating func reset() -> [DispatchWorkItem] {
let timeouts = pendingProbes.values.map(\.timeout)
pendingProbes.removeAll()
responseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
return timeouts
}
}

View File

@ -261,8 +261,6 @@ struct BLEOutboundFragmentTransferScheduler {
continue
}
availableSlots -= 1
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
@ -270,11 +268,17 @@ struct BLEOutboundFragmentTransferScheduler {
}
guard activeTransfers[transferId] == nil else {
// Blocked on an already-active copy of this content: leave
// the slot budget untouched so a later, unrelated pending
// transfer can still start in this same pass instead of
// being starved until some other transfer happens to
// complete.
blockedFront.append(request)
results.append(.queued(request: request, transferId: transferId, position: .front))
continue
}
availableSlots -= 1
activeTransfers[transferId] = ActiveTransferState(
totalFragments: 0,
sentFragments: 0,

View File

@ -15,7 +15,15 @@ enum BLEOutboundPacketPolicy {
// 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:
//
// announceV2 is unpadded too, but for a different reason and it is worth
// revisiting: it is ~75 bytes, so the smallest bucket would triple the
// airtime of the most frequently sent packet in the protocol. Its length
// is already near-constant by construction (the tag block is fixed
// width); the residual variation is the capability width and whether a
// bridge geohash is present. Making those fixed-width would be cheaper
// than padding. See docs/PEER-ID-ROTATION.md.
case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
return false
}
}
@ -27,6 +35,13 @@ enum BLEOutboundPacketPolicy {
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
case .fileTransfer:
return .fileTransfer
case .announceV2:
// Stated rather than inherited from `default`. Presence is small,
// time-bounded to its epoch, and useless once stale, so it belongs
// with the other control traffic at high priority but that should
// be a decision on the record, not a fall-through, since this type
// is not emitted yet and nobody would notice the choice being made.
return .high
default:
return .high
}

View File

@ -223,7 +223,7 @@ struct BLEPeerRegistry {
peers[peerID] = BLEPeerInfo(
peerID: existing?.peerID ?? peerID,
nickname: nickname,
nickname: nickname.normalizedNickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
// Never drop an already-pinned signing key.

View File

@ -0,0 +1,84 @@
import BitFoundation
import Foundation
/// Lock-backed shared ownership of the peer registry, readable from any
/// queue or the main actor without hopping onto a transport queue.
///
/// Mutations come only from the transport's own serial queues the
/// engine, plus the bleQueue link-drop paths that mark a peer
/// disconnected and the lock serializes them against each other and
/// against readers, so the main actor answers questions like
/// `isPeerConnected` without blocking behind in-flight transport work.
/// Every `BLEPeerRegistry` mutation is a single whole-transition method,
/// so a reader between two mutations always observes a valid pre- or
/// post-state, never a torn one.
///
/// Closures passed to `read`/`mutate` run under the (non-recursive) lock
/// and must not call back into the store.
final class BLEPeerRegistryStore: @unchecked Sendable {
private let lock = NSLock()
private var registry = BLEPeerRegistry()
/// One consistent view across multiple registry reads.
func read<T>(_ body: (BLEPeerRegistry) -> T) -> T {
lock.withLock { body(registry) }
}
func mutate<T>(_ body: (inout BLEPeerRegistry) -> T) -> T {
lock.withLock { body(&registry) }
}
// MARK: - Single-question reads
var isEmpty: Bool { read { $0.isEmpty } }
var peerIDs: [PeerID] { read { $0.peerIDs } }
var connectedCount: Int { read { $0.connectedCount } }
var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } }
var connectedRoutingData: [Data] { read { $0.connectedRoutingData } }
var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } }
func info(for peerID: PeerID) -> BLEPeerInfo? {
read { $0.info(for: peerID) }
}
func isConnected(_ peerID: PeerID) -> Bool {
read { $0.isConnected(peerID) }
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
read { $0.isReachable(peerID, now: now) }
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
read { $0.nickname(for: peerID, connectedOnly: connectedOnly) }
}
func fingerprint(for peerID: PeerID) -> String? {
read { $0.fingerprint(for: peerID) }
}
func capabilities(for peerID: PeerID) -> PeerCapabilities {
read { $0.capabilities(for: peerID) }
}
func advertisedBridgeGeohash() -> String? {
read { $0.advertisedBridgeGeohash() }
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
read { $0.displayNicknames(selfNickname: selfNickname) }
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
read { $0.transportSnapshots(selfNickname: selfNickname) }
}
/// Peers advertising `capability` that are reachable now, in one
/// consistent view.
func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] {
read { registry in
registry.peers(advertising: capability)
.filter { registry.isReachable($0, now: now) }
}
}
}

View File

@ -0,0 +1,363 @@
import BitFoundation
import Foundation
struct BLEAuthenticatedPeerStateObservation {
let fingerprint: String
let sessionGeneration: UUID
let capabilities: PeerCapabilities
}
struct BLEPrivateMediaProofTimeoutMarker {
let fingerprint: String
let sessionGeneration: UUID?
}
struct BLEPrivateMediaProofWatchdog {
let fingerprint: String
let sessionGeneration: UUID
let timeoutNonce: UUID
}
struct BLEPendingPrivateMediaPolicyResolution {
let fingerprint: String
var sessionGeneration: UUID?
var timeoutNonce: UUID
var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void]
}
struct BLEAuthenticatedPeerStateSendProgress {
let sessionGeneration: UUID
var sentInitial = false
var sentEcho = false
}
/// Lock-backed private-media session state: which Noise generation each
/// peer's capability proof, peer-state exchange, and policy waiters are
/// bound to. A fresh Noise authentication rotates the generation UUID, so
/// stale proof timers and proof packets cannot classify a replacement
/// session.
///
/// Lock-backed rather than engine-confined for two reasons: the send
/// policy is answered synchronously on the main actor, and several
/// transitions run inside noise-manager critical sections that the engine
/// is sync-waiting on (where re-entering the engine would self-deadlock,
/// but taking a leaf lock is safe). Every method is one whole transition
/// under the lock, so no caller can observe a torn intermediate state.
final class BLEPrivateMediaSessionStore: @unchecked Sendable {
private let lock = NSLock()
private var sessionGenerations: [PeerID: UUID] = [:]
private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:]
private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:]
private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:]
private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:]
private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:]
/// Peers whose parked outbound queues must stay parked until the
/// convergence retry re-authenticates: a timeout-restore brings back
/// keys the counterpart may have already discarded, so nothing not
/// even the capability-proof watchdog may drain the queues under
/// them. Set on the deferred restore transition, cleared by any
/// transition that is allowed to drain.
private var outboundConvergenceDeferred: Set<PeerID> = []
// MARK: Reads
func currentGeneration(for peerID: PeerID) -> UUID? {
lock.withLock { sessionGenerations[peerID] }
}
/// The exact current generation iff it authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? {
lock.withLock {
guard let generation = sessionGenerations[peerID],
generation == currentNoiseGeneration,
let authenticated = authenticatedStates[peerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia),
authenticated.capabilities.contains(.privateMediaReceipts) else {
return nil
}
return generation
}
}
/// One consistent view of the state the send-policy calculus needs.
func policyInputs(for peerID: PeerID) -> (
sessionGeneration: UUID?,
authenticatedState: BLEAuthenticatedPeerStateObservation?,
timedOut: BLEPrivateMediaProofTimeoutMarker?
) {
lock.withLock {
(
sessionGenerations[peerID],
authenticatedStates[peerID],
proofTimeoutMarkers[peerID]
)
}
}
func hasPendingPolicyResolution(for peerID: PeerID) -> Bool {
lock.withLock { pendingPolicyResolutions[peerID] != nil }
}
/// The live proof-timeout identity for a peer (watchdog first, then a
/// registered waiter) what a forced/expired timeout must present.
func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? {
lock.withLock {
if let watchdog = proofWatchdogs[peerID] {
return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce)
}
if let pending = pendingPolicyResolutions[peerID] {
return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce)
}
return nil
}
}
// MARK: Generation transitions
/// Installs a freshly authenticated generation: rotates the proof
/// watchdog, resets peer-state send progress, and re-binds any pending
/// policy waiters whose fingerprint still matches (mismatched waiters
/// are rejected and returned for completion). Returns nil when the
/// generation is already current the same-generation reconciliation
/// path, which must not re-arm proof machinery.
func beginAuthenticatedGeneration(
for peerID: PeerID,
fingerprint: String,
generation: UUID
) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? {
lock.withLock {
guard sessionGenerations[peerID] != generation else { return nil }
let watchdogNonce = UUID()
sessionGenerations[peerID] = generation
authenticatedStates.removeValue(forKey: peerID)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: watchdogNonce
)
stateSendProgress[peerID] =
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
guard var pending = pendingPolicyResolutions[peerID] else {
return (watchdogNonce, [])
}
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
pendingPolicyResolutions.removeValue(forKey: peerID)
return (watchdogNonce, Array(pending.completions.values))
}
pending.sessionGeneration = generation
pending.timeoutNonce = watchdogNonce
pendingPolicyResolutions[peerID] = pending
return (watchdogNonce, [])
}
}
/// Records a verified authenticated-peer-state packet for the current
/// generation: pins the observation, retires proof timers, and releases
/// matching policy waiters. Returns nil when the generation is no longer
/// current (the caller's lease raced a replacement).
func applyAuthenticatedPeerState(
for peerID: PeerID,
fingerprint: String,
generation: UUID,
capabilities: PeerCapabilities
) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? {
lock.withLock {
guard sessionGenerations[peerID] == generation else { return nil }
authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation(
fingerprint: fingerprint,
sessionGeneration: generation,
capabilities: capabilities
)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs.removeValue(forKey: peerID)
guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID),
pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.sessionGeneration == generation else {
return []
}
return Array(pending.completions.values)
}
}
/// Consumes one peer-state send slot (initial or echo) for the current
/// generation. Returns whether the packet should actually go out.
func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool {
lock.withLock {
guard let generation = sessionGenerations[peerID],
var progress = stateSendProgress[peerID],
progress.sessionGeneration == generation else { return false }
if echo {
guard !progress.sentEcho else { return false }
progress.sentEcho = true
} else {
guard !progress.sentInitial else { return false }
progress.sentInitial = true
}
stateSendProgress[peerID] = progress
return true
}
}
// MARK: Outbound convergence deferral
func setOutboundDeferredUntilConvergence(_ peerID: PeerID) {
lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) }
}
func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) {
lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) }
}
// MARK: Proof timeout
/// Expires a proof deadline if its nonce/generation/fingerprint still
/// identify the live watchdog or waiter set. On expiry the timeout
/// marker is pinned and any waiters are returned for completion.
/// `deferredOutbound` reports whether the peer's parked queues must
/// stay parked (timeout-restore pending its convergence retry).
func expireProofDeadline(
for peerID: PeerID,
fingerprint: String,
sessionGeneration: UUID?,
nonce: UUID
) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) {
lock.withLock {
let pending = pendingPolicyResolutions[peerID]
let pendingMatches = pending?.timeoutNonce == nonce
&& pending?.sessionGeneration == sessionGeneration
&& pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
let watchdog = proofWatchdogs[peerID]
let watchdogMatches = sessionGeneration != nil
&& watchdog?.timeoutNonce == nonce
&& watchdog?.sessionGeneration == sessionGeneration
&& watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
guard pendingMatches || watchdogMatches else {
return (false, false, [])
}
var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = []
if pendingMatches, let pending {
completions = Array(pending.completions.values)
}
if pendingMatches {
pendingPolicyResolutions.removeValue(forKey: peerID)
}
if watchdogMatches {
proofWatchdogs.removeValue(forKey: peerID)
}
proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker(
fingerprint: fingerprint,
sessionGeneration: sessionGeneration
)
return (true, outboundConvergenceDeferred.contains(peerID), completions)
}
}
/// Registers a policy-resolution waiter for a peer still awaiting its
/// capability proof. Joins the existing waiter set when fingerprints
/// match (bounded), otherwise starts one, reusing the live watchdog's
/// deadline identity when it covers the same fingerprint/generation so
/// only one timeout is ever in flight. `shouldSchedule` tells the
/// caller to arm a fresh deadline.
func registerPolicyResolution(
for peerID: PeerID,
fingerprint: String,
requestID: UUID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) {
lock.withLock {
let generation = sessionGenerations[peerID]
if var pending = pendingPolicyResolutions[peerID] {
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.completions.count
< TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else {
return (false, false, UUID(), generation)
}
pending.completions[requestID] = completion
pendingPolicyResolutions[peerID] = pending
return (true, false, pending.timeoutNonce, pending.sessionGeneration)
}
guard pendingPolicyResolutions.count
< TransportConfig.privateMediaCapabilityProofPendingPeerCap else {
return (false, false, UUID(), generation)
}
let currentWatchdog = proofWatchdogs[peerID]
let reusesWatchdog = currentWatchdog?.fingerprint
.caseInsensitiveCompare(fingerprint) == .orderedSame
&& currentWatchdog?.sessionGeneration == generation
let nonce: UUID
if reusesWatchdog, let currentWatchdog {
nonce = currentWatchdog.timeoutNonce
} else {
nonce = UUID()
}
pendingPolicyResolutions[peerID] =
BLEPendingPrivateMediaPolicyResolution(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: nonce,
completions: [requestID: completion]
)
return (true, !reusesWatchdog, nonce, generation)
}
}
// MARK: Teardown
/// A session clear retires every generation-bound record. Waiters are
/// kept but rebased onto a nil generation with a fresh deadline nonce,
/// returned so the caller re-arms their timeout.
func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? {
lock.withLock {
sessionGenerations.removeValue(forKey: peerID)
authenticatedStates.removeValue(forKey: peerID)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs.removeValue(forKey: peerID)
stateSendProgress.removeValue(forKey: peerID)
outboundConvergenceDeferred.remove(peerID)
guard var pending = pendingPolicyResolutions[peerID] else {
return nil
}
let nonce = UUID()
pending.sessionGeneration = nil
pending.timeoutNonce = nonce
pendingPolicyResolutions[peerID] = pending
return (pending.fingerprint, nonce)
}
}
/// Panic wipe: these records belong to pre-panic transfer state, and
/// invoking their callbacks would let queued UI work recreate or resend
/// wiped media drop everything.
func panicReset() {
lock.withLock {
sessionGenerations.removeAll()
authenticatedStates.removeAll()
proofTimeoutMarkers.removeAll()
proofWatchdogs.removeAll()
pendingPolicyResolutions.removeAll()
stateSendProgress.removeAll()
outboundConvergenceDeferred.removeAll()
}
}
}
extension BLEPrivateMediaSessionStore {
/// The current generation iff its authenticated peer state proved the
/// private-media capability (and, when required, durable receipts).
func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? {
let inputs = policyInputs(for: peerID)
guard let generation = inputs.sessionGeneration,
let authenticated = inputs.authenticatedState,
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia) else { return nil }
if requireReceipts {
guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil }
}
return generation
}
}

View File

@ -0,0 +1,411 @@
import BitLogger
import CoreBluetooth
import Foundation
/// The radio's contact points back into the transport. All calls arrive on
/// bleQueue.
protocol BLERadioControllerDelegate: AnyObject {
/// Whether a panic wipe has quiesced the radio.
func radioIsPanicSuspended() -> Bool
/// iOS app-active snapshot (drives allow-duplicates scanning and
/// background connect deferral); always true on macOS.
func radioIsAppActive() -> Bool
/// A connect attempt died (timeout or foreground stale-reclaim): retire
/// the link's transport bookkeeping write buffers, link-auth proof,
/// reconnect epoch, and the link-state entry itself.
func radioTearDownPeripheralLink(_ peripheralID: String)
}
/// bleQueue-confined owner of the central-role radio policy: discovery
/// admission, the connection budget and queue, connect timeouts,
/// wake-on-proximity background connects, scan duty-cycling, RSSI
/// adaptation, and the advertising payload.
///
/// First slice of the link layer (docs/BLE-ARCHITECTURE-V3.md): this type
/// makes no peer decisions and owns no bindings or security state it
/// shares the bleQueue-confined link-state store for admission reads and
/// asks its delegate to tear down transport bookkeeping when an attempt
/// dies.
final class BLERadioController {
weak var delegate: BLERadioControllerDelegate?
/// The transport is every peripheral's CBPeripheralDelegate; connects
/// initiated here must point new peripherals at it.
weak var peripheralDelegate: CBPeripheralDelegate?
/// Attached when the transport creates (or restores) its managers.
weak var central: CBCentralManager?
private let queue: DispatchQueue
private let linkStateStore: BLELinkStateStore
private let recentTraffic: BLERecentTrafficMonitor
// Connection budget & scheduling (central role)
private var scheduler = BLEConnectionScheduler<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer?
private var dutyEnabled: Bool = true
private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
private var dutyActive: Bool = false
init(
queue: DispatchQueue,
linkStateStore: BLELinkStateStore,
recentTraffic: BLERecentTrafficMonitor
) {
self.queue = queue
self.linkStateStore = linkStateStore
self.recentTraffic = recentTraffic
}
// MARK: - Advertising
static func advertisementData() -> [String: Any] {
// No Local Name for privacy.
[CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]]
}
// MARK: - Scanning
func startScanning() {
guard delegate?.radioIsPanicSuspended() == false,
let central,
central.state == .poweredOn,
!central.isScanning else { return }
// Allow duplicates while active for faster discovery: immediate
// discovery events instead of coalesced ones.
let allowDuplicates = delegate?.radioIsAppActive() ?? true
central.scanForPeripherals(
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
)
}
func updateScanningDutyCycle(connectedCount: Int) {
guard let central, central.state == .poweredOn else { return }
// Duty cycle only when the app is active and at least one peer is
// connected; force full-time scanning with few neighbors or very
// recent traffic.
let hasRecentTraffic = recentTraffic.hasTraffic(
within: TransportConfig.bleRecentTrafficForceScanSeconds,
now: Date()
)
let scanPlan = BLEScanDutyPolicy.plan(
dutyEnabled: dutyEnabled,
appIsActive: delegate?.radioIsAppActive() ?? true,
connectedCount: connectedCount,
hasRecentTraffic: hasRecentTraffic
)
switch scanPlan {
case .dutyCycle(let onDuration, let offDuration):
let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration
dutyOnDuration = onDuration
dutyOffDuration = offDuration
if scanDutyTimer == nil {
// Start with scanning ON; turn OFF after onDuration.
let t = DispatchSource.makeTimerSource(queue: queue)
if !central.isScanning { startScanning() }
dutyActive = true
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
t.setEventHandler { [weak self] in
guard let self, let c = self.central else { return }
if self.dutyActive {
if c.isScanning { c.stopScan() }
self.dutyActive = false
self.queue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
if self.central?.state == .poweredOn { self.startScanning() }
self.dutyActive = true
}
}
}
t.resume()
scanDutyTimer = t
} else if durationsChanged {
scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
if !central.isScanning { startScanning() }
dutyActive = true
}
case .continuous:
// Cancel duty cycle and ensure scanning is ON for discovery.
scanDutyTimer?.cancel()
scanDutyTimer = nil
if !central.isScanning { startScanning() }
}
}
func stopDutyCycle() {
scanDutyTimer?.cancel()
scanDutyTimer = nil
}
func updateRSSIThreshold(connectedCount: Int) {
scheduler.updateRSSIThreshold(
connectedCount: connectedCount,
connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
now: Date()
)
}
// MARK: - Discovery & connection budget
func handleDiscovery(
_ peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi: NSNumber
) {
guard delegate?.radioIsPanicSuspended() == false, let central else { return }
let peripheralID = peripheral.identifier.uuidString
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "")
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
let candidate = BLEConnectionCandidate(
peripheral: peripheral,
peripheralID: peripheralID,
rssi: rssi.intValue,
name: String(advertisedName),
isConnectable: isConnectable,
discoveredAt: Date()
)
if isConnectable {
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
}
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
switch scheduler.handleDiscovery(
candidate,
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
existingState: existingState,
peripheralState: peripheral.state.connectionSchedulerState,
now: candidate.discoveredAt
) {
case .ignore, .queued:
return
case .scheduleRetry(let delay):
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.tryConnectFromQueue()
}
return
case .cancelStaleConnection:
central.cancelPeripheralConnection(peripheral)
return
case .connectNow:
beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect")
}
}
func tryConnectFromQueue() {
guard delegate?.radioIsPanicSuspended() == false,
let central,
central.state == .poweredOn else { return }
let decision = scheduler.nextCandidate(
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in
let state = linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
},
now: Date()
)
switch decision {
case .none:
return
case .retryAfter(let delay):
queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
case .connect(let candidate):
beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect")
}
}
private func beginCentralConnection(
_ candidate: BLEConnectionCandidate<CBPeripheral>,
using central: CBCentralManager,
logPrefix: String
) {
guard delegate?.radioIsPanicSuspended() == false else { return }
let peripheral = candidate.peripheral
let peripheralID = candidate.peripheralID
linkStateStore.beginConnecting(to: peripheral, at: Date())
peripheral.delegate = peripheralDelegate
let options: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
scheduler.recordConnectionAttempt(at: Date())
SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
guard let self,
let state = self.linkStateStore.state(forPeripheralID: peripheralID),
state.isConnecting && !state.isConnected else { return }
guard peripheral.state != .connected else {
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session)
return
}
if self.delegate?.radioIsAppActive() == false {
// Backgrounded: leave the connect pending. iOS never expires
// it the controller completes it whenever the peer comes
// back into range, waking the app (state restoration
// relaunches us if we were terminated). Foreground return
// cancels stale pendings via cancelStalePendingConnects().
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
return
}
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.delegate?.radioTearDownPeripheralLink(peripheralID)
self.scheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
}
}
// MARK: - Scheduler bookkeeping (called from the transport's delegates)
var candidateCount: Int { scheduler.candidateCount }
func recordConnectionSuccess(peripheralID: String) {
scheduler.recordConnectionSuccess(peripheralID: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
scheduler.recordConnectionFailure(peripheralID: peripheralID)
}
func recordDisconnectError(peripheralID: String, at date: Date) {
scheduler.recordDisconnectError(peripheralID: peripheralID, at: date)
}
func recordRecentPeripheral(_ peripheral: CBPeripheral, peripheralID: String, at date: Date) {
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: date)
}
func pruneConnectionTimeouts(before cutoff: Date) {
scheduler.pruneConnectionTimeouts(before: cutoff)
}
/// Panic wipe: drop the candidate queue, backoff state, and RSSI
/// adaptation with the identity they served.
func reset() {
scheduler.reset()
}
#if os(iOS)
// MARK: - Background wake-on-proximity
/// Backgrounding hands the freed connection budget to iOS as pending
/// connects against recently seen peers: the controller completes one
/// whenever its peer comes into range, waking (or relaunching) the app.
/// A couple of central slots stay reserved for connects driven by live
/// background discovery except on the disconnect re-arm path, which
/// may consume the slot the disconnect itself just freed (a dense mesh
/// with 4+ remaining links would otherwise compute a zero budget and
/// never re-arm the lost peer).
func armPendingBackgroundConnects(
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
) {
queue.async { [weak self] in
guard let self,
self.delegate?.radioIsPanicSuspended() == false,
let central = self.central,
central.state == .poweredOn else { return }
let budget = TransportConfig.bleMaxCentralLinks
- slotReserve
- self.linkStateStore.connectedOrConnectingPeripheralCount
let now = Date()
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
}
guard !targets.isEmpty else { return }
for target in targets {
// lastConnectionAttempt stays nil: an indefinite pending
// connect has no attempt clock, and nil marks it always-stale
// so cancelStalePendingConnects() reclaims it on foreground
// even after a quick backgroundforeground bounce.
self.linkStateStore.setPeripheralState(
BLEPeripheralLinkState(
peripheral: target.peripheral,
characteristic: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: target.peripheralID
)
target.peripheral.delegate = self.peripheralDelegate
central.connect(target.peripheral, options: [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
])
}
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
}
}
/// Foreground restores normal connection management: pending connects
/// older than the connect timeout (including ones rebuilt by state
/// restoration after a relaunch) are cancelled so live scanning and the
/// scheduler take over. Anything still nearby is rediscovered within
/// seconds by the allow-duplicates foreground scan.
func cancelStalePendingConnects() {
queue.async { [weak self] in
guard let self, let central = self.central else { return }
let now = Date()
var cancelled = 0
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
let peripheralID = state.peripheral.identifier.uuidString
central.cancelPeripheralConnection(state.peripheral)
self.delegate?.radioTearDownPeripheralLink(peripheralID)
cancelled += 1
}
if cancelled > 0 {
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
self.tryConnectFromQueue()
}
}
}
#endif
}
// MARK: - Connection scheduling helpers
private extension BLEExistingConnectionState {
init(_ state: BLEPeripheralLinkState) {
self.init(
isConnecting: state.isConnecting,
isConnected: state.isConnected,
lastConnectionAttempt: state.lastConnectionAttempt
)
}
}
private extension CBPeripheralState {
var connectionSchedulerState: BLEPeripheralConnectionState {
switch self {
case .connected:
return .connected
case .connecting:
return .connecting
case .disconnected, .disconnecting:
return .disconnected
@unknown default:
return .disconnected
}
}
}

View File

@ -77,6 +77,26 @@ struct BLEReceivePipeline {
}
}
/// Lock-backed traffic-level signal: the receive pipeline records packets,
/// and the radio layer (maintenance and scan-duty adaptation on bleQueue)
/// reads the level without crossing onto a transport queue.
final class BLERecentTrafficMonitor: @unchecked Sendable {
private let lock = NSLock()
private var tracker = BLERecentTrafficTracker()
func recordPacket(at now: Date) {
lock.withLock { tracker.recordPacket(at: now) }
}
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
lock.withLock { tracker.hasTraffic(within: seconds, now: now) }
}
func removeAll() {
lock.withLock { tracker.removeAll() }
}
}
struct BLERecentTrafficTracker: Equatable {
private var packetTimestamps: [Date] = []

View File

@ -21,24 +21,53 @@ enum BLERedundantLinkPolicy {
/// A link mid-service-rediscovery (didModifyServices cleared it)
/// must never be kept over a writable duplicate.
let hasCharacteristic: Bool
/// When didConnect last fired for this link in this process. Nil
/// for restored links, whose connect predates the relaunch.
let lastConnectedAt: Date?
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
init(
uuid: String,
peerID: PeerID?,
isConnected: Bool,
hasCharacteristic: Bool,
lastConnectedAt: Date? = nil
) {
self.uuid = uuid
self.peerID = peerID
self.isConnected = isConnected
self.hasCharacteristic = hasCharacteristic
self.lastConnectedAt = lastConnectedAt
}
}
/// The link to keep when a peer has several connected bound peripheral
/// links, or nil when there is nothing to consolidate. Prefers the
/// ingress link of the verified direct announce that triggered the check
/// (the strongest liveness proof available), falling back to the peer's
/// most recently bound link but only among writable links while any
/// exist: keeping a characteristic-less link and cancelling the writable
/// links, or nil when there is nothing to consolidate.
///
/// Prefers the most recently CONNECTED candidate. Duplicates arise when
/// the peer reappears under a fresh BLE address (privacy address
/// rotation) while an older connection typically state-restored
/// lives on: only the newest connection sits on the address the peer
/// still advertises. Cancelling that one instead just gets it
/// rediscovered and reconnected, a retirereconnect oscillation at the
/// retirement cooldown (field-observed July 31); the older-address link
/// cannot return once cancelled, so consolidation converges immediately.
/// Physical connect recency is also a signal an announce replay cannot
/// nominate, unlike the previous ingress-link preference announce
/// anchors (ingress, then most recently bound) now only break ties and
/// serve links with no connect timestamp at all. Link "health" signals
/// like RSSI are deliberately not inputs: they are transient and the
/// stale-address link often reads stronger; connect recency is the only
/// signal that tracks address currency.
///
/// The survivor must be writable while any writable candidate exists:
/// keeping a characteristic-less link and cancelling the writable
/// duplicate would strand outbound traffic on the central link until
/// rediscovery finishes. When neither anchor is a viable candidate,
/// consolidation waits for a later announce rather than guessing.
/// rediscovery finishes. But when the physically NEWEST connection is
/// the one that is not writable yet (service discovery still running),
/// consolidation defers entirely selecting an older writable link
/// would cancel the freshly advertised connection and recreate the
/// oscillation. When no candidate is identifiable, consolidation waits
/// for a later announce rather than guessing.
static func keptPeripheralUUID(
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?,
@ -51,6 +80,42 @@ enum BLERedundantLinkPolicy {
let writable = bound.filter(\.hasCharacteristic)
let candidates = writable.isEmpty ? bound : writable
// The newest connection is still mid-service-discovery while a
// writable (typically restored, stale-address) duplicate exists:
// defer to a later announce instead of keeping the older link and
// cancelling the one connection on the currently advertised address.
if !writable.isEmpty,
let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(),
!writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) {
return nil
}
if let newestDate = candidates.compactMap(\.lastConnectedAt).max() {
let newest = candidates.filter { $0.lastConnectedAt == newestDate }
if newest.count == 1 {
return newest[0].uuid
}
return anchoredChoice(
among: newest,
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: mostRecentlyBoundUUID
) ?? newest.map(\.uuid).min()
}
return anchoredChoice(
among: candidates,
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: mostRecentlyBoundUUID
)
}
/// The pre-timestamp anchors: the verified announce's ingress link,
/// then the peer's most recently bound link.
private static func anchoredChoice(
among candidates: [PeripheralLink],
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?
) -> String? {
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
return ingressPeripheralUUID
}

View File

@ -0,0 +1,433 @@
//
// BLEService+LinkLayerCentralRole.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 CoreBluetooth
import Foundation
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
// physical bookkeeping (link-state store, buffers, radio policy) and report
// everything else to the engine through the link-event port
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
// MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
guard !isPanicSuspended else {
central.stopScan()
restoredPeripherals.forEach {
central.cancelPeripheralConnection($0)
}
return
}
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
SecureLogger.info(
"♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
category: .session
)
for peripheral in restoredPeripherals {
let identifier = peripheral.identifier.uuidString
peripheral.delegate = self
let existing = linkStateStore.state(forPeripheralID: identifier)
let assembler = existing?.assembler ?? NotificationStreamAssembler()
let characteristic = existing?.characteristic
let wasConnecting = existing?.isConnecting ?? false
let wasConnected = existing?.isConnected ?? false
let restoredState = BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: characteristic,
isConnecting: wasConnecting || peripheral.state == .connecting,
isConnected: wasConnected || peripheral.state == .connected,
lastConnectionAttempt: existing?.lastConnectionAttempt,
assembler: assembler
)
linkStateStore.setPeripheralState(restoredState, for: identifier)
// Restored peripherals are the freshest wake-on-proximity
// candidates we have after a relaunch without this the cache
// starts empty and backgrounding right after a restore arms
// nothing. Service rediscovery for restored-connected links waits
// for poweredOn: CoreBluetooth drops commands issued during
// restoration (API MISUSE warnings).
radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date())
}
// Via the sampler (not a direct capture): it refreshes the cached
// background budget on main first, so the restore log shows the real
// wake window instead of the init sentinel.
logBluetoothStatus("central-restore")
if central.state == .poweredOn {
radio.startScanning()
}
}
#endif
func centralManagerDidUpdateState(_ central: CBCentralManager) {
emitTransportEvent(.bluetoothStateUpdated(central.state))
switch central.state {
case .poweredOn:
guard !isPanicSuspended else {
central.stopScan()
return
}
// Links restored as connected have no characteristic in the new
// process; without rediscovery they sit connected-but-unusable
// until the peer disconnects. Runs here (not willRestoreState)
// because commands issued before poweredOn are dropped.
for state in linkStateStore.peripheralStates where state.isConnected
&& state.characteristic == nil
&& state.peripheral.state == .connected {
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))", category: .session)
state.peripheral.discoverServices([BLEService.serviceUUID])
}
// Start scanning - use allow duplicates for faster discovery when active
radio.startScanning()
case .poweredOff:
// CoreBluetooth has already transitioned out of poweredOn. Do
// not issue stop/cancel commands now; they are rejected as API
// misuse. Retire our link state locally instead.
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString }
for peripheralID in peripheralIDs {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
linkStateStore.clearPeripherals()
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true))
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
linkStateStore.clearPeripherals()
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false))
case .unsupported:
// Device doesn't support BLE
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
case .resetting:
// Bluetooth stack is resetting - will get another state update when done
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
case .unknown:
// Initial state before we know the actual state
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard !isPanicSuspended else {
central.cancelPeripheralConnection(peripheral)
return
}
let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
// A connect completing while backgrounded is the wake-on-proximity
// path doing its job worth an info line for field verification.
if !isAppActive {
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
}
#endif
// Update state to connected
linkStateStore.markConnected(peripheral)
// Reset backoff state on success
radio.recordConnectionSuccess(peripheralID: peripheralID)
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
// Discover services
peripheral.discoverServices([BLEService.serviceUUID])
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
radio.recordDisconnectError(peripheralID: peripheralID, at: Date())
}
// Retain the handle: a dropped link is the best wake-on-proximity
// candidate if the app backgrounds before the peer returns.
radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date())
#if os(iOS)
// Link lost while backgrounded (peer walked away): re-arm a pending
// connect during this wake window so the peer's return wakes us again.
// Delayed past the disconnect-settle window to avoid reconnect thrash
// at range edge.
if !isAppActive {
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
guard let self, !self.isAppActive else { return }
// Reserve 0: use the slot this disconnect freed even in a
// dense mesh, so the lost peer can wake us when it returns.
self.radio.armPendingBackgroundConnects(slotReserve: 0)
}
}
#endif
// Physical teardown now; identity retirement and peer-disconnect
// bookkeeping ride the link-event port. The scan restart and
// connect-slot refill below stay on bleQueue they respond to
// the physical drop regardless of remaining logical links.
discardPeripheralLinkPhysical(peripheralID)
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true))
// Restart scanning with allow duplicates for faster rediscovery
if centralManager?.state == .poweredOn {
// Stop and restart scanning to ensure we get fresh discovery events
centralManager?.stopScan()
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
self?.radio.startScanning()
}
}
// Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
// Clean up the references: physical now, identity via the port.
discardPeripheralLinkPhysical(peripheralID)
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false))
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
radio.recordConnectionFailure(peripheralID: peripheralID)
// Try next candidate
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
}
}
// MARK: - CBPeripheralDelegate
extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
// Retry service discovery after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return }
peripheral.discoverServices([BLEService.serviceUUID])
}
return
}
guard let services = peripheral.services else {
SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
return
}
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
// Not a BitChat peer - disconnect
centralManager?.cancelPeripheralConnection(peripheral)
return
}
// Discovering BLE characteristics
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
return
}
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
return
}
// Found characteristic
// Log characteristic properties for debugging
var properties: [String] = []
if characteristic.properties.contains(.read) { properties.append("read") }
if characteristic.properties.contains(.write) { properties.append("write") }
if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
if characteristic.properties.contains(.notify) { properties.append("notify") }
if characteristic.properties.contains(.indicate) { properties.append("indicate") }
// Characteristic properties: \(properties.joined(separator: ", "))
// Verify characteristic supports reliable writes
if !characteristic.properties.contains(.write) {
SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
}
// Store characteristic in our consolidated structure
let peripheralID = peripheral.identifier.uuidString
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
// Subscribe for notifications
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
// Send announce after subscription is confirmed (force send for new connection)
engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
// Try flushing any spooled directed packets now that we have a link
self?.flushDirectedSpool()
}
} else {
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
return
}
guard let data = characteristic.value, !data.isEmpty else {
SecureLogger.warning("⚠️ No data in notification", category: .session)
return
}
bufferNotificationChunk(data, from: peripheral)
}
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
let peripheralUUID = peripheral.identifier.uuidString
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
isConnecting: false,
isConnected: peripheral.state == .connected,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
linkStateStore.setPeripheralState(state, for: peripheralUUID)
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
}
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
// Attribution spoof rejection, announce binding, ingress
// recording is engine work now (the engine owns the bindings).
// Frames hop up in decode order; the engine's serial slot ordering
// gives the same same-batch spoof protection the old bleQueue-side
// batch-local binding enforced: an announce that binds this link is
// attributed before every frame that rode behind it.
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
emitLinkEvent(.frameDecoded(
packet,
link: .peripheral(peripheralUUID),
linkDescription: "Peripheral \(peripheralUUID.prefix(8))"
))
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
// Don't retry - just log the error
} else {
SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
}
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
guard !isPanicSuspended else { return }
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
}
drainPendingWrites(for: peripheral)
}
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
guard !isPanicSuspended else { return }
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
)
guard shouldRediscover else { return }
let peripheralID = peripheral.identifier.uuidString
linkStateStore.updatePeripheral(peripheralID) {
$0.characteristic = nil
$0.assembler = NotificationStreamAssembler()
}
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
peripheral.discoverServices([BLEService.serviceUUID])
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
} else {
SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
// If notifications are now on, send an announce to ensure this peer knows about us
if characteristic.isNotifying {
// Sending announce after subscription
self.sendAnnounce(forceSend: true)
}
}
}
}
extension BLEService {
static func shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
) -> Bool {
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
}
}

View File

@ -0,0 +1,320 @@
//
// BLEService+LinkLayerPeripheralRole.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 CoreBluetooth
import Foundation
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
// physical bookkeeping (link-state store, buffers, radio policy) and report
// everything else to the engine through the link-event port
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
// MARK: - CBPeripheralManagerDelegate
extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
switch peripheral.state {
case .poweredOn:
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
// Remove all services first to ensure clean state
peripheral.removeAllServices()
// Create characteristic
characteristic = CBMutableCharacteristic(
type: BLEService.characteristicUUID,
properties: [.notify, .write, .writeWithoutResponse, .read],
value: nil,
permissions: [.readable, .writeable]
)
// Create service
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate)
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
peripheral.add(service)
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
// Clear subscribed centrals (they are now invalid)
let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString }
pendingNotifications.removeAll()
pendingWriteBuffers.removeAll()
linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true))
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false))
case .unsupported:
// Device doesn't support BLE peripheral role
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
case .resetting:
// Bluetooth stack is resetting
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
case .unknown:
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
}
}
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
SecureLogger.info(
"♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
category: .session
)
// Attempt to recover characteristic from restored services
if characteristic == nil {
if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
characteristic = restoredCharacteristic
}
}
// Via the sampler for a fresh background budget (see central-restore).
logBluetoothStatus("peripheral-restore")
if peripheral.state == .poweredOn && !peripheral.isAdvertising {
peripheral.startAdvertising(BLERadioController.advertisementData())
}
}
#endif
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
return
}
if let error = error {
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
return
}
SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
// Start advertising after service is confirmed added
let adData = BLERadioController.advertisementData()
peripheral.startAdvertising(adData)
SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
guard !isPanicSuspended else { return }
let centralUUID = central.identifier.uuidString
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))", category: .session)
linkStateStore.addSubscribedCentral(central)
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
let now = Date()
switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
case .allowed:
break
case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
if suppressAnnounce {
SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
return
}
// Still flush directed packets for legitimate mesh operation
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.flushDirectedSpool()
}
return
}
// Send announce to the newly subscribed central after a small delay
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed
self?.flushDirectedSpool()
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
let centralID = central.identifier.uuidString
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))", category: .session)
// bleQueue: physical retirement now.
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if !isPanicSuspended, peripheral.isAdvertising == false {
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(BLERadioController.advertisementData())
}
// Identity retirement and peer-disconnect bookkeeping ride the
// link-event port.
emitLinkEvent(.centralLinkEnded(centralUUID: centralID))
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
guard !isPanicSuspended else { return }
drainPendingNotifications(logPrefix: "✅ Sent")
}
func logBackpressureSampled(_ message: @autoclosure () -> String) {
notificationBackpressureLogCount += 1
if notificationBackpressureLogCount == 1 ||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
}
}
func drainPendingNotifications(logPrefix: String) {
bleQueue.async { [weak self] in
guard let self = self,
let characteristic = self.characteristic,
!self.pendingNotifications.isEmpty else { return }
let pending = self.pendingNotifications.takeAll()
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
if sentCount > 0 {
self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
}
}
}
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
var sentCount = 0
for (index, notification) in pending.enumerated() {
let success = peripheralManager?.updateValue(
notification.data,
for: characteristic,
onSubscribedCentrals: notification.targets
) ?? false
guard success else {
let remaining = Array(pending.dropFirst(index))
pendingNotifications.prepend(remaining)
logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
break
}
sentCount += 1
}
return sentCount
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise
if requests.count > 1 {
SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
}
// IMPORTANT: Respond immediately to prevent timeouts!
// We must respond within a few milliseconds or the central will timeout
for request in requests {
peripheral.respond(to: request, withResult: .success)
}
guard !isPanicSuspended else { return }
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
// Combine per-central request values by offset before decoding.
// Process directly on our message queue to match transport context
let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
for (centralUUID, group) in grouped {
// Sort by offset ascending
let sorted = group.sorted { $0.offset < $1.offset }
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
guard let data = request.value, !data.isEmpty else { return nil }
return BLEInboundWriteChunk(offset: request.offset, data: data)
}
let result = pendingWriteBuffers.append(
chunks: chunks,
for: centralUUID,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
switch result {
case let .decoded(packet, metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
case let .waiting(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
case let .oversized(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))", category: .session)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
}
}
}
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
guard let packetType = metadata.packetType,
packetType != MessageType.announce.rawValue else { return }
SecureLogger.debug(
"📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
category: .session
)
}
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
}
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
// bleQueue: physical bookkeeping only. A writer is a live central
// whether or not it subscribed; track it so directed replies and
// the fanout planner can reach it.
linkStateStore.addSubscribedCentral(central)
// Attribution is engine work (the engine owns the bindings).
emitLinkEvent(.frameDecoded(
packet,
link: .central(centralUUID),
linkDescription: "Central \(centralUUID.prefix(8))"
))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,8 @@ final class BoardManager: ObservableObject {
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
/// Board broadcast rides the mesh only; absent on other transports.
private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting }
/// Publishes a bridged kind-1 note (expiring with the board post via
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
/// was skipped.
@ -122,7 +124,7 @@ final class BoardManager: ObservableObject {
flags: flags,
signature: signature
)
transport.sendBoardPayload(BoardWire.post(post).encode())
boardTransport?.sendBoardPayload(BoardWire.post(post).encode())
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
@ -148,7 +150,7 @@ final class BoardManager: ObservableObject {
deletedAt: deletedAt,
signature: signature
)
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
// Merged delete: also retract the bridged Nostr copy when we still
// know its event id.

View File

@ -0,0 +1,49 @@
//
// ChannelShare.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Builds plain-text location-channel invites for the system share sheet (#1497).
///
/// Text-first on purpose: a `bitchat://` deep link is dead weight for people
/// who have not installed yet, and SMS does not reliably linkify custom
/// schemes. The payload always includes the App Store URL and the geohash a
/// person can type under location channels after installing.
enum ChannelShare {
/// App Store listing used in out-of-app invites.
static let appStoreURL = "https://apps.apple.com/us/app/bitchat-mesh/id6748219622"
/// Neighborhood (6) and finer imply a small cell sharing that over SMS
/// discloses location interest to the carrier and both handsets.
static let precisionWarningMinimumLength = 6
static func shouldWarn(forGeohash geohash: String) -> Bool {
geohash.count >= precisionWarningMinimumLength
}
/// Channel-not-presence framing: "join #x", never "I'm in #x".
static func payload(forGeohash geohash: String) -> String {
let gh = geohash.lowercased()
return String(
format: String(
localized: "channel.share.payload",
defaultValue: "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels.",
comment: "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL"
),
locale: .current,
gh,
appStoreURL
)
}
}
/// Identifiable wrapper so `.sheet(item:)` can present the system share UI.
struct ChannelSharePayload: Identifiable {
let id = UUID()
let text: String
}

View File

@ -90,6 +90,9 @@ protocol CommandContextProvider: AnyObject {
final class CommandProcessor {
weak var contextProvider: CommandContextProvider?
weak var meshService: Transport?
/// Mesh-only command surfaces, absent when the transport lacks them.
private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing }
private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving }
private let identityManager: SecureIdentityStateManagerProtocol
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
@ -371,7 +374,7 @@ final class CommandProcessor {
}
// Scrub their carried public messages now, while the peerID is
// resolvable, so they can't resurface as archived echoes.
meshService?.purgeArchivedPublicMessages(from: peerID)
meshArchive?.purgeArchivedPublicMessages(from: peerID)
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
@ -474,7 +477,7 @@ final class CommandProcessor {
// meshPingTimeoutSeconds later, and reading the selected chat at
// callback time would misroute the result after a chat switch.
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
@ -496,7 +499,7 @@ final class CommandProcessor {
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded

View File

@ -0,0 +1,152 @@
import BitFoundation
import CoreBluetooth
import Foundation
/// Optional transport capabilities, discovered with `as?` instead of casting
/// to a concrete transport class. `Transport` stays the contract every
/// transport genuinely implements; a capability protocol here is the
/// contract for one mesh-only feature surface, so app wiring depends on the
/// feature it needs rather than on `BLEService` itself.
/// Radio-state reporting for transports backed by a local radio.
protocol BluetoothStateReporting: AnyObject {
func getCurrentBluetoothState() -> CBManagerState
}
/// Panic-mode lifecycle for transports that own durable identity state.
/// A transport implementing this owns its own restart sequencing:
/// `completePanicReset` decides whether services come back, so generic
/// `startServices()` calls after a panic belong only to transports that
/// don't implement it.
protocol PanicResettingTransport: AnyObject {
/// Quiesces the radio and drains in-flight work ahead of a panic wipe.
func suspendForPanicReset()
/// Finishes a panic wipe, optionally restarting services.
func completePanicReset(restartServices: Bool)
/// Rotates the transport identity as part of a panic reset.
func resetIdentityForPanic(currentNickname: String, restartServices: Bool)
}
/// File and private-media transfer over a mesh transport, including the
/// capability-proof policy that gates encrypted private media.
protocol MeshFileTransferring: AnyObject {
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
}
/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`,
/// fire-and-forget inside the Noise session (private) or as a signed
/// ephemeral broadcast (public). Frames are only useful now the
/// transport drops them (never queues) without an established session.
protocol MeshVoiceStreaming: AnyObject {
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
func sendVoiceFrameBroadcast(_ burstContent: Data)
}
/// Courier store-and-forward: seal a message to the recipient's static
/// key and hand it to connected couriers for physical delivery while the
/// recipient is offline. Returns false when the transport cannot courier.
protocol MeshCourierTransporting: AnyObject {
@discardableResult
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
}
/// Private groups: creator-signed state travels 1:1 over Noise sessions;
/// group messages flood like public broadcasts.
protocol MeshGroupMessaging: AnyObject {
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
}
/// Bulletin board: broadcast a pre-signed board payload (post or
/// tombstone) so it spreads over relay and gossip sync.
protocol MeshBoardBroadcasting: AnyObject {
func sendBoardPayload(_ payload: Data)
}
/// Mesh diagnostics (/ping, /trace, topology map).
protocol MeshDiagnosing: AnyObject {
/// Sends a directed ping probe; the completion fires exactly once on
/// the main actor with the measured result, or nil on timeout.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map.
func currentMeshTopology() -> MeshTopologySnapshot?
}
/// QR verification and transitive vouching over the Noise session.
protocol MeshVerifying: AnyObject {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
}
/// Store-and-forward archive: the public messages this device is carrying
/// for gossip sync, decoded for display as "heard here earlier" echoes.
protocol MeshPublicArchiving: AnyObject {
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
/// Erases the whole carried public-message archive, on disk included.
func purgeAllArchivedPublicMessages()
}
/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today).
/// Everything the gateway/bridge/courier services need from the mesh
/// transport, so their bootstrap wiring never touches the concrete class.
protocol MeshBridgingTransport: AnyObject {
// Runtime-advertised capability bits
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool)
func setLocalBridgeGeohash(_ cell: String?)
func advertisedBridgeGeohash() -> String?
// Peers currently advertising bridging roles
func reachableGatewayPeers() -> [PeerID]
func reachableBridgePeers() -> [PeerID]
// Gateway carrier packets (mesh <-> Nostr uplink/downlink)
@discardableResult
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool
func broadcastNostrCarrier(_ payload: Data)
/// Sink for received carrier packets (set once by app wiring; called on
/// the main actor after transport-level checks).
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set }
// Bridge courier drops (sealed envelopes carried across the bridge)
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope?
@discardableResult
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool
func myNoiseStaticPublicKey() -> Data
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)]
/// Fired (off-main) when a signature-verified announce is processed.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set }
}

View File

@ -104,12 +104,10 @@ final class LRUDeduplicationCache<Value> {
enum ContentNormalizer {
/// Regex to simplify HTTP URLs by stripping query strings and fragments
private static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
}()
private static let simplifyHTTPURL = SafeRegex.compile(
"https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
/// Normalizes content for deduplication comparison.
/// - Parameters:

View File

@ -39,37 +39,23 @@ final class MessageFormattingEngine {
/// Precompiled regex patterns for message content parsing
enum Patterns {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)")
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b")
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b")
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+")
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
}
// MARK: - Match Types
@ -124,11 +110,12 @@ final class MessageFormattingEngine {
)
// Format content
let myNickname = context.nickname.normalizedNickname
let contentResult = formatContent(
message.content,
baseColor: baseColor,
isSelf: isSelf,
isMentioned: message.mentions?.contains(context.nickname) ?? false
isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
)
result.append(contentResult)

View File

@ -281,6 +281,7 @@ final class MessageRouter {
guard remainingSlots > 0 else { return }
for transport in transports {
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
let couriers = eligibleCouriers(
on: transport,
recipientKey: recipientKey,
@ -288,7 +289,7 @@ final class MessageRouter {
limit: remainingSlots
)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
@ -304,6 +305,7 @@ final class MessageRouter {
/// `maxCouriersPerMessage` distinct couriers or expires.
func courierBecameAvailable(_ peerID: PeerID) {
for transport in transports {
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
guard transport.isPeerConnected(peerID),
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
let courierKey = snapshot.noisePublicKey,
@ -319,7 +321,7 @@ final class MessageRouter {
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
!message.depositedCourierKeys.contains(courierKey),
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
onMessageCarried?(message.messageID, recipient)

View File

@ -1089,6 +1089,10 @@ final class NoiseEncryptionService {
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
try initiateAutomaticRekey(for: peerID)
}
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
sessionManager._test_fireSuppressedInitiationRecovery(for: peerID)
}
#endif
deinit {

View File

@ -203,14 +203,12 @@ final class PrivateChatManager: ObservableObject {
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
for message in messages(for: peerID) {
if message.sender == nickname {
if let status = message.deliveryStatus {
switch status {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent, .carried:
break
}
switch message.deliveryStatus {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .notSentYet, .failed, .partiallyDelivered, .sending, .sent, .carried:
break
}
}
}

View File

@ -203,99 +203,14 @@ protocol Transport: AnyObject {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
// `VoiceBurstPacket`, fire-and-forget inside the Noise session. Frames are
// only useful now transports drop them (never queue) when no
// established session exists.
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
// Public-mesh counterpart: signed ephemeral broadcast, never synced.
func sendVoiceFrameBroadcast(_ burstContent: Data)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Private groups (mesh transports only): creator-signed state travels
// 1:1 over Noise sessions; group messages flood like public broadcasts.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Vouching / transitive verification (optional for transports)
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
/// handshake-required callback, so secondary features can observe
/// session establishment without disturbing the primary registration.
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
// Store-and-forward archive (mesh transports only): the public messages
// this device is carrying for gossip sync, decoded for display as
// "heard here earlier" timeline echoes.
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
/// Erases the whole carried public-message archive, on disk included, so
/// clearing the mesh timeline deletes that history rather than hiding it.
func purgeAllArchivedPublicMessages()
}
/// A carried public mesh message from the store-and-forward window, decoded
@ -341,72 +256,12 @@ extension Transport {
onHandshakeRequired: @escaping (PeerID) -> Void
) {}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaSendPolicy(to: peerID)
Task { @MainActor in
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
}
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {}
func sendVoiceFrameBroadcast(_ burstContent: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
Task { @MainActor in completion([]) }
}
func purgeArchivedPublicMessages(from peerID: PeerID) {}
func purgeAllArchivedPublicMessages() {}
}
protocol TransportPeerEventsDelegate: AnyObject {
@ -450,3 +305,14 @@ extension BitchatDelegate {
}
extension BLEService: Transport {}
extension BLEService: MeshFileTransferring {}
extension BLEService: MeshVoiceStreaming {}
extension BLEService: MeshCourierTransporting {}
extension BLEService: MeshGroupMessaging {}
extension BLEService: MeshBoardBroadcasting {}
extension BLEService: MeshDiagnosing {}
extension BLEService: MeshVerifying {}
extension BLEService: MeshPublicArchiving {}
extension BLEService: BluetoothStateReporting {}
extension BLEService: PanicResettingTransport {}
extension BLEService: MeshBridgingTransport {}

View File

@ -195,7 +195,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen,
isConnected: peerInfo.isConnected,
isReachable: isReachable
isReachable: isReachable,
localPetname: localPetname(forFingerprint: fingerprint)
)
// Check for favorite status
@ -218,7 +219,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated,
isConnected: false,
isReachable: false
isReachable: false,
localPetname: localPetname(forFingerprint: favorite.peerNoisePublicKey.sha256Fingerprint())
)
peer.favoriteStatus = favorite
@ -227,6 +229,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return peer
}
/// Rebuild peer rows after a social-identity write (local alias, etc.) so
/// display names update without waiting for a mesh event.
func refreshPeers() {
updatePeers()
}
private func localPetname(forFingerprint fingerprint: String?) -> String? {
guard let fingerprint,
let petname = identityManager.getSocialIdentity(for: fingerprint)?.localPetname,
!petname.isEmpty else {
return nil
}
return petname
}
// MARK: - Public Methods
/// Get peer by ID
@ -236,8 +253,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
/// Get peer ID for nickname
func getPeerID(for nickname: String) -> PeerID? {
// Normalize both sides: the query may come from typed content and
// stored names may predate NFC-at-ingest (e.g. persisted favorites).
let target = nickname.normalizedNickname
for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname {
if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target {
return peer.peerID
}
}
@ -279,7 +299,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Purge while the fingerprintpeerID mapping is still known: the
// archived-echo seed filter can't resolve offline strangers, so
// scrub their carried messages now rather than at relaunch.
meshService.purgeArchivedPublicMessages(from: peerID)
(meshService as? MeshPublicArchiving)?.purgeArchivedPublicMessages(from: peerID)
}
updatePeers()
return fingerprint

View File

@ -57,6 +57,11 @@ struct SyncTypeFlags: OptionSet {
// Live voice is only useful now; replaying stale audio frames via
// sync would waste airtime (receivers drop them as stale anyway).
case .voiceFrame: return nil
// Rotating-ID presence is valid only inside its epoch, and gossiping it
// would defeat the point: a synced announce would let a device that was
// never in radio range collect tag blocks, turning a local presence
// beacon into a network-wide one.
case .announceV2: return nil
// Prekey bundles gossip like board posts. The bitfield is a
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
// ignored by `type(forBit:)`), so bits 8+ need no format change: old

View File

@ -39,9 +39,10 @@ struct InputValidator {
return trimmed
}
/// Validates nickname
/// Validates nickname and returns it in canonical (NFC) form so
/// visually identical names always compare equal.
static func validateNickname(_ nickname: String) -> String? {
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname
}
// MARK: - Protocol Field Validation

View File

@ -0,0 +1,113 @@
import Foundation
/// The one-tap "quick join" suggestion in the channels sheet: the
/// region-level geohash channel around the device region's main population
/// center. Derived from the device locale no location access, no GPS; the
/// tap reuses the same path as typing the geohash and teleporting.
///
/// This replaced an earlier curated list of heavily censored countries. A
/// hand-picked roster invites disputes over who is on it and goes stale
/// with every political shift; deriving the suggestion from the locale
/// gives every country the same treatment under one rule.
///
/// Quick join is channel discovery, not protection: region cells are
/// public, well known, and trivially enumerable, so the suggested channel
/// must be assumed watched. Joining it hides nothing and bypasses nothing.
struct QuickJoinSuggestion {
let regionCode: String
let geohash: String
/// Regional-indicator flag emoji derived from the ISO code.
var flag: String {
regionCode.unicodeScalars.reduce(into: "") { result, scalar in
if let indicator = Unicode.Scalar(127397 + scalar.value) {
result.unicodeScalars.append(indicator)
}
}
}
var localizedName: String {
Locale.current.localizedString(forRegionCode: regionCode) ?? regionCode
}
/// The suggestion for the device's region, or nil when the region is
/// unknown or unmapped (the section is hidden then).
static func current(for locale: Locale = .current) -> QuickJoinSuggestion? {
guard let code = locale.region?.identifier.uppercased(),
let geohash = regionCells[code] else { return nil }
return QuickJoinSuggestion(regionCode: code, geohash: geohash)
}
/// ISO 3166-1 alpha-2 region the 2-character geohash cell over the
/// country's main population center the largest metro, not always the
/// capital (US New York, TR Istanbul, MM Yangon), because that is
/// the cell where a country's channel actually forms. Someone elsewhere
/// in the country lands in this cell too; the caption in the sheet says
/// so rather than calling it "your country's channel".
///
/// Coverage is every UN member state plus inhabited territories a
/// device locale plausibly reports; a missing code just hides the row.
/// Cells are 11.25° × 5.625°, so city-level coordinates are ample. The
/// table is generated by geohashing each center's coordinates; the
/// twelve entries the earlier roster shipped were independently
/// verified in review and reproduce unchanged, and entries near a cell
/// boundary were checked by hand. Distinct countries can legitimately
/// share a cell (Seoul and Pyongyang are both "wy") cells are big.
private static let regionCells: [String: String] = [
"AD": "sp", "AE": "th", "AF": "tw", "AG": "de",
"AL": "sr", "AM": "sz", "AO": "kq", "AR": "69",
"AT": "u2", "AU": "r3", "AW": "d6", "AZ": "tp",
"BA": "sr", "BB": "dd", "BD": "wh", "BE": "u1",
"BF": "ef", "BG": "sx", "BH": "th", "BI": "kx",
"BJ": "s1", "BM": "dt", "BN": "w8", "BO": "6s",
"BR": "6g", "BS": "dk", "BT": "tu", "BW": "ke",
"BY": "u9", "BZ": "d5", "CA": "dp", "CD": "kr",
"CF": "s2", "CG": "kr", "CH": "u0", "CI": "eb",
"CL": "66", "CM": "s0", "CN": "wx", "CO": "d2",
"CR": "d1", "CU": "dh", "CV": "e6", "CW": "d6",
"CY": "sw", "CZ": "u2", "DE": "u3", "DJ": "sf",
"DK": "u3", "DM": "dd", "DO": "d7", "DZ": "sn",
"EC": "6p", "EE": "ud", "EG": "st", "ER": "sf",
"ES": "ez", "ET": "sc", "FI": "ud", "FJ": "ru",
"FM": "x9", "FO": "gg", "FR": "u0", "GA": "s0",
"GB": "gc", "GD": "dd", "GE": "sz", "GF": "db",
"GG": "gb", "GH": "eb", "GI": "ey", "GL": "fg",
"GM": "ed", "GN": "e9", "GP": "dd", "GQ": "s0",
"GR": "sw", "GT": "9f", "GU": "x4", "GW": "ed",
"GY": "d9", "HK": "we", "HN": "d4", "HR": "u2",
"HT": "d7", "HU": "u2", "ID": "qq", "IE": "gc",
"IL": "sv", "IM": "gc", "IN": "tt", "IQ": "sv",
"IR": "tn", "IS": "ge", "IT": "sr", "JE": "gb",
"JM": "d7", "JO": "sv", "JP": "xn", "KE": "kz",
"KG": "tx", "KH": "w6", "KI": "xb", "KM": "kv",
"KN": "de", "KP": "wy", "KR": "wy", "KW": "tj",
"KY": "d5", "KZ": "tx", "LA": "w7", "LB": "sy",
"LC": "dd", "LI": "u0", "LK": "tc", "LR": "ec",
"LS": "kd", "LT": "u9", "LU": "u0", "LV": "ud",
"LY": "sm", "MA": "ev", "MC": "sp", "MD": "u8",
"ME": "sr", "MG": "mh", "MH": "xc", "MK": "sr",
"ML": "ef", "MM": "w4", "MN": "y2", "MO": "we",
"MQ": "dd", "MR": "ee", "MT": "sq", "MU": "mk",
"MV": "t8", "MW": "kv", "MX": "9g", "MY": "w2",
"MZ": "ke", "NA": "k7", "NC": "rs", "NE": "s4",
"NG": "s1", "NI": "d4", "NL": "u1", "NO": "u4",
"NP": "tu", "NR": "rx", "NZ": "rc", "OM": "tk",
"PA": "d1", "PE": "6m", "PF": "2s", "PG": "rq",
"PH": "wd", "PK": "tk", "PL": "u3", "PR": "de",
"PS": "sv", "PT": "ey", "PW": "wc", "PY": "6e",
"QA": "th", "RE": "mh", "RO": "sx", "RS": "sr",
"RU": "uc", "RW": "kx", "SA": "th", "SB": "rw",
"SC": "mp", "SD": "sd", "SE": "u6", "SG": "w2",
"SI": "u2", "SK": "u2", "SL": "e9", "SM": "sr",
"SN": "ed", "SO": "t0", "SR": "dc", "SS": "s8",
"ST": "s0", "SV": "d4", "SY": "sv", "SZ": "ke",
"TD": "s6", "TG": "s1", "TH": "w4", "TJ": "tw",
"TL": "qy", "TM": "tq", "TN": "sn", "TO": "2h",
"TR": "sx", "TT": "d9", "TV": "ry", "TW": "ws",
"TZ": "ky", "UA": "u8", "UG": "s8", "US": "dr",
"UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd",
"VE": "d9", "VI": "de", "VN": "w7", "VU": "rs",
"WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj",
"ZA": "ke", "ZM": "kt", "ZW": "ks",
]
}

View File

@ -0,0 +1,36 @@
//
// SafeRegex.swift
// bitchat
//
// Non-trapping construction for the app's compiled-in regex patterns.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
enum SafeRegex {
/// Compiles a bundled pattern. On failure it logs and returns a regex
/// that can never match, so a bad pattern degrades that one feature
/// instead of crashing at startup.
static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression {
do {
return try NSRegularExpression(pattern: pattern, options: options)
} catch {
SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session)
return neverMatching
}
}
/// `(?!)` an empty negative lookahead always compiles and can never match.
private static let neverMatching: NSRegularExpression = {
if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) {
return regex
}
// Unreachable: "(?!)" is a valid ICU pattern. The inherited plain
// initializer (empty pattern) is the least-bad non-trapping fallback
// if ICU itself were ever broken.
return NSRegularExpression()
}()
}

View File

@ -9,6 +9,14 @@
import Foundation
extension String {
/// Canonical form for nickname storage and comparison (Unicode NFC).
/// "café" typed with a combining accent and "café" typed precomposed
/// must resolve to the same user wherever nicknames are stored or
/// matched (mentions, DM resolution, autocomplete, geo presence).
var normalizedNickname: String {
precomposedStringWithCanonicalMapping
}
/// Split a nickname into base and a '#abcd' suffix if present
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")

View File

@ -31,6 +31,10 @@ protocol ChatComposerContext: AnyObject {
/// The transport's own nickname (excluded from autocomplete candidates).
var meshNickname: String { get }
func meshPeerNicknames() -> [PeerID: String]
/// True when this mesh nickname belongs to a blocked peer.
func isMeshNicknameBlocked(_ nickname: String) -> Bool
/// True when this geohash pubkey is blocked for location chats.
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
// MARK: Geohash identity (shared with the other contexts)
var geoNicknames: [String: String] { get }
@ -40,8 +44,8 @@ protocol ChatComposerContext: AnyObject {
extension ChatViewModel: ChatComposerContext {
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
// `geoNicknames`, `meshPeerNicknames()`, and
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// `geoNicknames`, `meshPeerNicknames()`, `isNostrBlocked(pubkeyHexLowercased:)`,
// and `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten nested service accesses into intent-named calls.
@ -60,6 +64,13 @@ extension ChatViewModel: ChatComposerContext {
var meshNickname: String {
meshService.myNickname
}
func isMeshNicknameBlocked(_ nickname: String) -> Bool {
for (peerID, nick) in meshService.getPeerNicknames() where nick == nickname {
if isPeerBlocked(peerID) { return true }
}
return false
}
}
@MainActor
@ -136,11 +147,14 @@ private extension ChatComposerCoordinator {
switch context.activeChannel {
case .mesh:
let values = context.meshPeerNicknames().values
return Array(values.filter { $0 != context.meshNickname })
return Array(values.filter { nick in
nick != context.meshNickname && !context.isMeshNicknameBlocked(nick)
})
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in context.geoNicknames {
guard !context.isNostrBlocked(pubkeyHexLowercased: pubkey) else { continue }
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {

View File

@ -105,16 +105,19 @@ extension ChatViewModel: ChatGroupContext {
identityManager.isBlocked(fingerprint: fingerprint)
}
/// Group state rides the mesh's Noise sessions only.
private var groupTransport: MeshGroupMessaging? { meshService as? MeshGroupMessaging }
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupInvite(payload, to: peerID)
groupTransport?.sendGroupInvite(payload, to: peerID)
}
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupKeyUpdate(payload, to: peerID)
groupTransport?.sendGroupKeyUpdate(payload, to: peerID)
}
func broadcastGroupMessagePayload(_ payload: Data) {
meshService.broadcastGroupMessage(payload)
groupTransport?.broadcastGroupMessage(payload)
}
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)

View File

@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext {
}
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
if let radio = meshService as? BluetoothStateReporting {
updateBluetoothState(radio.getCurrentBluetoothState())
}
}
@ -360,9 +360,9 @@ private extension ChatLifecycleCoordinator {
}
}
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
guard let status else { return 0 }
func deliveryStatusRank(_ status: DeliveryStatus) -> Int {
switch status {
case .notSentYet: return 0
case .failed: return 1
case .sending: return 2
case .sent: return 3

View File

@ -353,7 +353,11 @@ final class ChatLiveVoiceCoordinator {
// Eviction skips voice_live_* names, so partials still streaming in
// are safe no matter which caller triggers enforcement.
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
fileManager.createFile(atPath: fileURL.path, contents: nil)
fileManager.createFile(
atPath: fileURL.path,
contents: nil,
attributes: BLEIncomingFileStore.mediaProtectionAttributes
)
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
try? fileManager.removeItem(at: fileURL)

View File

@ -151,14 +151,19 @@ extension ChatViewModel: ChatMediaTransferContext {
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
/// File transfer rides the mesh only. Without that capability the
/// policy degrades to the safe floor (blocked), matching the old
/// inert protocol defaults.
private var fileTransport: MeshFileTransferring? { meshService as? MeshFileTransferring }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
meshService.privateMediaSendPolicy(to: peerID)
fileTransport?.privateMediaSendPolicy(to: peerID) ?? .blockedDowngrade
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
meshService.authenticatedPrivateMediaReceiptSessionGeneration(
fileTransport?.authenticatedPrivateMediaReceiptSessionGeneration(
to: peerID
)
}
@ -167,7 +172,11 @@ extension ChatViewModel: ChatMediaTransferContext {
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
guard let fileTransport else {
Task { @MainActor in completion(.blockedDowngrade) }
return
}
fileTransport.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
}
func requestLegacyPrivateMediaConsent(
@ -197,7 +206,7 @@ extension ChatViewModel: ChatMediaTransferContext {
transferId: String,
allowLegacyFallback: Bool
) {
meshService.sendFilePrivate(
fileTransport?.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
@ -210,7 +219,7 @@ extension ChatViewModel: ChatMediaTransferContext {
to peerID: PeerID,
transferId: String
) {
meshService.sendFilePrivateReceiptRetry(
fileTransport?.sendFilePrivateReceiptRetry(
packet,
to: peerID,
transferId: transferId
@ -218,11 +227,11 @@ extension ChatViewModel: ChatMediaTransferContext {
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId)
fileTransport?.sendFileBroadcast(packet, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId)
fileTransport?.cancelTransfer(transferId)
}
func removeUntombstonedMediaMessage(withID messageID: String) {
@ -1890,7 +1899,7 @@ private extension ChatMediaTransferCoordinator {
try FileManager.default.createDirectory(
at: filesDirectory,
withIntermediateDirectories: true,
attributes: nil
attributes: BLEIncomingFileStore.mediaProtectionAttributes
)
return filesDirectory
}

View File

@ -41,7 +41,9 @@ final class ChatMessageFormatter {
}()
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
let isVerifiedSender = !isSelf && isVerifiedSender(of: message)
let cacheVariant = theme.formatCacheVariant + (isVerifiedSender ? "-vf" : "")
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: cacheVariant) {
return cachedText
}
@ -66,6 +68,12 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
// Private rows render a filled SF Symbol seal beside the lock
// (TextMessageView / MediaMessageView); skip the in-string there
// so verified DMs don't show two markers.
if isVerifiedSender, !message.isPrivate {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
let content = message.content
@ -183,7 +191,8 @@ final class ChatMessageFormatter {
allMatches.sort { $0.range.location < $1.range.location }
var lastEnd = content.startIndex
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
let myNickname = viewModel.nickname.normalizedNickname
let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
for (range, type) in allMatches {
guard let swiftRange = Range(range, in: content) else { continue }
@ -335,7 +344,7 @@ final class ChatMessageFormatter {
result.append(timestamp.mergingAttributes(timestampStyle))
}
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: cacheVariant)
return result
}
@ -356,6 +365,7 @@ final class ChatMessageFormatter {
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
let isVerifiedSender = !isSelf && isVerifiedSender(of: message)
if message.sender == "system" {
var style = AttributeContainer()
@ -381,6 +391,9 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender, !message.isPrivate {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
@ -427,6 +440,29 @@ final class ChatMessageFormatter {
}
private extension ChatMessageFormatter {
/// Whether the message sender has a fingerprint the user has verified.
/// Used for the in-chat seal next to `<@name>` so verification is visible
/// without opening the fingerprint sheet (#1439).
func isVerifiedSender(of message: BitchatMessage) -> Bool {
guard let peerID = message.senderPeerID,
let fingerprint = viewModel.getFingerprint(for: peerID) else {
return false
}
return viewModel.peerIdentityStore.isVerified(fingerprint)
}
func appendVerifiedSeal(
to result: inout AttributedString,
baseColor: Color,
design: Font.Design
) {
var sealStyle = AttributeContainer()
// Match the peer-list verified seal: filled checkmark in the sender tint.
sealStyle.foregroundColor = baseColor
sealStyle.font = .bitchatSystem(size: 11, weight: .semibold, design: design)
result.append(AttributedString("").mergingAttributes(sealStyle))
}
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if spid.isGeoChat || spid.isGeoDM {

View File

@ -479,15 +479,21 @@ final class ChatPeerIdentityCoordinator {
return peerID.id
}
// Local aliases outrank announced nicknames so a saved petname is
// actually visible after the fingerprint sheet dismisses.
if let fingerprint = getFingerprint(for: peerID),
let identity = context.socialIdentity(forFingerprint: fingerprint),
let petname = identity.localPetname,
!petname.isEmpty {
return petname
}
if let nickname = context.meshPeerNicknames()[peerID] {
return nickname
}
if let fingerprint = getFingerprint(for: peerID),
let identity = context.socialIdentity(forFingerprint: fingerprint) {
if let petname = identity.localPetname {
return petname
}
return identity.claimedNickname
}
@ -503,6 +509,9 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
// Queries arrive from typed commands and message content, so bring
// them to the same canonical (NFC) form nicknames are stored in.
let nickname = nickname.normalizedNickname
switch context.activeChannel {
case .location:
if nickname.contains("#"),

View File

@ -506,14 +506,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func checkForMentions(_ message: BitchatMessage) {
var myTokens: Set<String> = [context.nickname]
let myNickname = context.nickname.normalizedNickname
var myTokens: Set<String> = [myNickname]
let meshPeers = context.meshPeerNicknames()
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") }
if !collisions.isEmpty {
let suffix = "#" + String(context.myPeerID.id.prefix(4))
myTokens = [context.nickname + suffix]
myTokens = [myNickname + suffix]
}
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false
if isMentioned && message.sender != context.nickname {
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)

View File

@ -129,12 +129,15 @@ extension ChatViewModel: ChatVerificationContext {
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
}
/// QR verification rides the mesh's Noise sessions only.
private var verifyTransport: MeshVerifying? { meshService as? MeshVerifying }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
verifyTransport?.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
verifyTransport?.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func postLocalNotification(title: String, body: String, identifier: String) {

View File

@ -176,10 +176,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
@Published var nickname: String = "" {
didSet {
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
if trimmed != nickname {
nickname = trimmed
// Canonicalize whenever nickname is set: trim whitespace
// (whitespace-only becomes "") and apply Unicode NFC so accented
// names match regardless of how they were typed.
let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname
if cleaned != nickname {
nickname = cleaned
return
}
// Update mesh service nickname if it's initialized
@ -1069,7 +1071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
}
func purgeArchivedPublicMessages() {
meshService.purgeAllArchivedPublicMessages()
(meshService as? MeshPublicArchiving)?.purgeAllArchivedPublicMessages()
}
/// Queues a system message for the next geohash channel visit. (Tiny
@ -1580,8 +1582,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Quiesce the mesh before clearing stores. Identity replacement below
// deliberately stays stopped until media deletion and marker commit.
if let bleService = meshService as? BLEService {
bleService.suspendForPanicReset()
if let panicTransport = meshService as? PanicResettingTransport {
panicTransport.suspendForPanicReset()
} else {
meshService.emergencyDisconnectAll()
}
@ -1730,8 +1732,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Replace the BLE identity while keeping the radio stopped. It may
// reopen only after the durable panic transaction commits.
if let bleService = meshService as? BLEService {
bleService.resetIdentityForPanic(
if let panicTransport = meshService as? PanicResettingTransport {
panicTransport.resetIdentityForPanic(
currentNickname: nickname,
restartServices: false
)
@ -1776,18 +1778,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
guard panicCompleted else { return false }
if let bleService = meshService as? BLEService {
if let panicTransport = meshService as? PanicResettingTransport {
// Startup recovery reopens admission but leaves actual service
// start to the bootstrapper immediately after this method.
bleService.completePanicReset(
panicTransport.completePanicReset(
restartServices: restartServices
)
}
if restartServices {
// All persistent state and media are gone. Bring each service back
// only now, under the new identity.
if !(meshService is BLEService) {
// only now, under the new identity a panic-resetting transport
// owns its own restart sequencing above.
if !(meshService is PanicResettingTransport) {
meshService.startServices()
}

View File

@ -195,9 +195,8 @@ private extension ChatViewModelBootstrapper {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
let bleService = viewModel.meshService as? BLEService else { return }
let state = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(state)
let radio = viewModel.meshService as? BluetoothStateReporting else { return }
viewModel.updateBluetoothState(radio.getCurrentBluetoothState())
}
viewModel.nostrRelayManager = NostrRelayManager.shared
@ -219,8 +218,9 @@ private extension ChatViewModelBootstrapper {
/// right after transport start, so give it a beat before asking.
private func loadArchivedEchoes() {
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel,
let archive = viewModel.meshService as? MeshPublicArchiving else { return }
archive.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel else { return }
// A previous /clear dismissed everything heard up to its
// watermark; only newer archive entries come back. Blocking a
@ -331,7 +331,7 @@ private extension ChatViewModelBootstrapper {
func configureGateway() {
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
// has no carrier packets to bridge.
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let gateway = GatewayService.shared
gateway.publishToRelays = { event, geohash in
@ -410,7 +410,7 @@ private extension ChatViewModelBootstrapper {
/// transport, the relay manager, location, and the public timeline. Same
/// closure-injection style as `configureGateway`.
func configureBridge() {
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let bridge = BridgeService.shared
let idBridge = viewModel.idBridge
@ -545,7 +545,7 @@ private extension ChatViewModelBootstrapper {
/// manager, the mesh transport's sealing/opening primitives, the courier
/// store, and the message router's deposit path.
func configureBridgeCourier() {
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let courier = BridgeCourierService.shared
courier.bridgeEnabled = { BridgeService.shared.isEnabled }

View File

@ -90,7 +90,7 @@ extension ChatViewModel: ChatVouchContext {
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
meshService.sendVouchAttestations(payload, to: peerID)
(meshService as? MeshVerifying)?.sendVouchAttestations(payload, to: peerID)
}
func notifyPeerTrustChanged() {

View File

@ -97,14 +97,17 @@ extension ChatViewModel {
/// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble.
@MainActor
func makeVoiceCaptureSession() -> VoiceCaptureSession {
// Live voice rides the mesh only; frames are useful now or never,
// so a transport without the capability just drops them.
let voiceTransport = meshService as? MeshVoiceStreaming
switch liveVoiceTarget() {
case .peer(let peerID):
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrame(packet, to: peerID)
return PTTLiveVoiceSession(sendPacket: { packet in
voiceTransport?.sendVoiceFrame(packet, to: peerID)
})
case .publicMesh:
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrameBroadcast(packet)
return PTTLiveVoiceSession(sendPacket: { packet in
voiceTransport?.sendVoiceFrameBroadcast(packet)
})
case nil:
SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session)

View File

@ -15,6 +15,8 @@ extension DeliveryStatus {
/// the glyphs alone are unexplained 10pt icons.
var bitchatDescription: String {
switch self {
case .notSentYet:
return String(localized: "content.delivery.not_sent_yet", defaultValue: "Not sent yet", comment: "Delivery status description for a message that has not entered any send pipeline")
case .sending:
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
@ -72,6 +74,13 @@ struct DeliveryStatusView: View {
@ViewBuilder
private var statusGlyph: some View {
switch status {
case .notSentYet:
// Normally hidden by callers; shown as a hollow dotted circle if
// it ever surfaces so the state is visible rather than invisible.
Image(systemName: "circle.dotted")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
@ -125,6 +134,7 @@ struct DeliveryStatusView: View {
#Preview {
let statuses: [DeliveryStatus] = [
.notSentYet,
.sending,
.sent,
.carried,

View File

@ -23,7 +23,7 @@ struct TextMessageView: View {
/// SAME instance would otherwise compare "unchanged" and this row's body
/// would be skipped even though the parent list re-rendered. Snapshotting
/// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus?
private let deliveryStatus: DeliveryStatus
@State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
@ -50,6 +50,15 @@ struct TextMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if conversationUIModel.showsVerifiedSeal(for: message) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.green.opacity(0.85))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
)
}
if message.isBridged {
Image(systemName: "network")
.font(.bitchatSystem(size: 8))
@ -68,11 +77,11 @@ struct TextMessageView: View {
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
deliveryStatus != .notSentYet {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
DeliveryStatusView(status: deliveryStatus)
.padding(.leading, 4)
.contentShape(Rectangle())
}
@ -86,15 +95,15 @@ struct TextMessageView: View {
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
deliveryStatus != .notSentYet {
if case .failed = deliveryStatus {
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)

View File

@ -2,6 +2,9 @@ import SwiftUI
#if os(iOS)
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@ -29,7 +32,7 @@ struct ContentComposerView: View {
VStack(alignment: .leading, spacing: 6) {
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
}) {
@ -43,6 +46,11 @@ struct ContentComposerView: View {
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
index == conversationUIModel.selectedAutocompleteIndex
? palette.secondary.opacity(0.15)
: Color.clear
)
}
.buttonStyle(.plain)
}
@ -73,7 +81,28 @@ struct ContentComposerView: View {
.textInputAutocapitalization(.sentences)
#endif
.submitLabel(.send)
.modifier(AutocompleteKeyboardNavigationModifier(
isActive: { conversationUIModel.showAutocomplete
&& !conversationUIModel.autocompleteSuggestions.isEmpty },
onMove: { delta in
conversationUIModel.moveAutocompleteSelection(by: delta)
},
onAccept: {
conversationUIModel.completeSelectedSuggestion(in: &messageText)
},
onDismiss: {
conversationUIModel.dismissAutocomplete()
}
))
// Return while the mention panel is open completes the
// highlight instead of sending matches command suggestions
// (#1504) and keeps Tab/Return/Escape on one convention.
.onSubmit {
if conversationUIModel.showAutocomplete,
!conversationUIModel.autocompleteSuggestions.isEmpty,
conversationUIModel.completeSelectedSuggestion(in: &messageText) {
return
}
onSendMessage()
// Only the return-key path: it steals focus on iOS, so
// every message would cost a tap to reopen the keyboard.
@ -374,3 +403,104 @@ private extension ContentComposerView {
)
}
}
/// Arrow/Tab/Return/Escape navigation for the mention suggestion list.
///
/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 /
/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on
/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself,
/// so arrow keys never reach SwiftUI while the composer has focus the
/// same reason command suggestions (#1504) use an `NSEvent` local monitor.
/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+.
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
/// Live activity check, not a captured Bool. The macOS monitor closure is
/// registered once for the view's lifetime; a plain `Bool` would freeze
/// the value captured at install time (this is a value type), so a panel
/// that opens after the monitor installs would never intercept a key.
/// The provider closes over the reference-typed model and reads current
/// state on every event.
let isActive: () -> Bool
let onMove: (Int) -> Void
let onAccept: () -> Bool
let onDismiss: () -> Void
#if os(macOS)
@State private var keyMonitor: Any?
#endif
func body(content: Content) -> some View {
#if os(macOS)
content
.onAppear { installKeyMonitor() }
.onDisappear { removeKeyMonitor() }
#else
if #available(iOS 17.0, *) {
content
.onKeyPress(.upArrow) {
guard isActive() else { return .ignored }
onMove(-1)
return .handled
}
.onKeyPress(.downArrow) {
guard isActive() else { return .ignored }
onMove(1)
return .handled
}
.onKeyPress(.tab) {
guard isActive() else { return .ignored }
return onAccept() ? .handled : .ignored
}
.onKeyPress(.escape) {
guard isActive() else { return .ignored }
onDismiss()
return .handled
}
} else {
content
}
#endif
}
#if os(macOS)
private func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
handleKeyDown(event)
}
}
private func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
}
keyMonitor = nil
}
/// Standard autocomplete navigation (aligned with #1504): arrows move
/// the highlight, return/tab insert, escape dismisses. Returning nil
/// consumes the event so return completes instead of sending while the
/// list is up. Inactive monitors pass everything through.
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
guard isActive(),
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
return event
}
switch event.keyCode {
case 126: // up arrow
onMove(-1)
return nil
case 125: // down arrow
onMove(1)
return nil
case 36, 48: // return, tab
return onAccept() ? nil : event
case 53: // escape
onDismiss()
return nil
default:
return event
}
}
#endif
}

View File

@ -30,6 +30,10 @@ struct ContentHeaderView: View {
/// timeline is showing) they should light the pin too.
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
@State private var pendingShareGeohash: String?
@State private var showSharePrecisionWarning = false
@State private var activeSharePayload: ChannelSharePayload?
/// The bridged-people count belongs to the mesh channel only.
private var showBridgedPeerCount: Bool {
if case .location = locationChannelsModel.selectedChannel { return false }
@ -213,6 +217,16 @@ struct ContentHeaderView: View {
channel.geohash
)
)
Button(action: { requestHeaderShare(forGeohash: channel.geohash) }) {
Image(systemName: "square.and.arrow.up")
.font(.bitchatSystem(size: 12))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Accessibility label for sharing the active location channel")
)
}
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
@ -336,8 +350,37 @@ struct ContentHeaderView: View {
} message: {
Text("content.alert.screenshot.message")
}
.confirmationDialog(
String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite"),
isPresented: $showSharePrecisionWarning,
titleVisibility: .visible
) {
Button(String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")) {
if let gh = pendingShareGeohash {
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: gh))
}
pendingShareGeohash = nil
}
Button("common.cancel", role: .cancel) {
pendingShareGeohash = nil
}
} message: {
Text(String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite"))
}
.sheet(item: $activeSharePayload) { payload in
ShareActivityView(text: payload.text)
}
.themedChromePanel(edge: .top)
}
private func requestHeaderShare(forGeohash geohash: String) {
if ChannelShare.shouldWarn(forGeohash: geohash) {
pendingShareGeohash = geohash
showSharePrecisionWarning = true
} else {
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
}
}
}
private extension View {

View File

@ -92,6 +92,9 @@ struct ContentView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@EnvironmentObject private var peerListModel: PeerListModel
@EnvironmentObject private var publicChatModel: PublicChatModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = ""
@ -183,7 +186,13 @@ struct ContentView: View {
!hasRootModalPresentation else {
return
}
appChromeModel.showBluetoothAlert = false
// SwiftUI can invoke this setter inside a view update (the
// alert dismisses when a scenePhase change re-evaluates the
// `get`); publishing synchronously there is undefined
// behavior, so defer the write one hop.
Task { @MainActor in
appChromeModel.showBluetoothAlert = false
}
}
)
}
@ -206,7 +215,11 @@ struct ContentView: View {
!hasRootModalPresentationBesidesVoiceAlert else {
return
}
voiceRecordingVM.showAlert = false
// Same deferral as the Bluetooth alert above: the setter can
// run inside a view update when the sheet state changes.
Task { @MainActor in
voiceRecordingVM.showAlert = false
}
}
)
}
@ -288,6 +301,17 @@ struct ContentView: View {
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
// Sheets + NavigationStack can drop inherited EnvironmentObjects on
// some iOS versions (#1558). Re-inject every model the sheet tree
// reads so ContentPeopleListView / MessageListView never crash.
.environmentObject(appChromeModel)
.environmentObject(privateConversationModel)
.environmentObject(verificationModel)
.environmentObject(conversationUIModel)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
.environmentObject(publicChatModel)
.environmentObject(privateInboxModel)
#else
ContentPeopleSheetView(
showSidebar: $appChromeModel.showSidebar,
@ -305,6 +329,14 @@ struct ContentView: View {
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
)
.environmentObject(appChromeModel)
.environmentObject(privateConversationModel)
.environmentObject(verificationModel)
.environmentObject(conversationUIModel)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
.environmentObject(publicChatModel)
.environmentObject(privateInboxModel)
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {

View File

@ -14,6 +14,8 @@ struct FingerprintView: View {
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@ThemedPalette private var palette
@State private var aliasDraft: String = ""
@State private var didLoadAlias = false
private var textColor: Color { palette.primary }
@ -26,6 +28,21 @@ struct FingerprintView: View {
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
static let localAlias = String(
localized: "fingerprint.local_alias.label",
defaultValue: "local alias",
comment: "Label for the local-only alias field on the fingerprint sheet"
)
static let localAliasPlaceholder = String(
localized: "fingerprint.local_alias.placeholder",
defaultValue: "name for this person",
comment: "Placeholder for the local alias field on the fingerprint sheet"
)
static let localAliasHint = String(
localized: "fingerprint.local_alias.hint",
defaultValue: "only on this device. leave blank to use their claimed nickname.",
comment: "Explanation under the local alias field"
)
static func verifyHint(_ nickname: String) -> String {
String(
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
@ -85,6 +102,26 @@ struct FingerprintView: View {
.padding()
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
if fingerprintState.canEditLocalAlias {
VStack(alignment: .leading, spacing: 8) {
Text(verbatim: Strings.localAlias)
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
TextField(Strings.localAliasPlaceholder, text: $aliasDraft)
.bitchatFont(size: 14)
.foregroundColor(textColor)
.padding(10)
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.onSubmit { commitAlias() }
Text(verbatim: Strings.localAliasHint)
.bitchatFont(size: 11)
.foregroundColor(textColor.opacity(0.6))
}
}
// Their fingerprint
VStack(alignment: .leading, spacing: 8) {
@ -248,6 +285,37 @@ struct FingerprintView: View {
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.themedSheetBackground()
.onAppear {
syncAliasDraft(from: fingerprintState, force: true)
}
.onChange(of: fingerprintState.theirFingerprint) { _ in
// Fingerprint can arrive after the sheet opens; load (or reload)
// the saved alias then, otherwise an empty draft looks like a clear.
syncAliasDraft(from: fingerprintState, force: false)
}
.onDisappear {
commitAlias()
}
}
/// Populate `aliasDraft` from the persisted petname once we know the
/// fingerprint. `force` reloads even if we already loaded (onAppear).
private func syncAliasDraft(from state: FingerprintPresentationState, force: Bool) {
guard state.canEditLocalAlias else { return }
if didLoadAlias && !force { return }
aliasDraft = state.localPetname ?? ""
didLoadAlias = true
}
private func commitAlias() {
let fingerprintState = verificationModel.fingerprintPresentation(for: peerID)
guard fingerprintState.canEditLocalAlias else { return }
// Don't treat "never loaded a draft" as an intentional clear.
guard didLoadAlias else { return }
let current = fingerprintState.localPetname ?? ""
let draft = aliasDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard draft != current else { return }
verificationModel.setLocalPetname(draft.isEmpty ? nil : draft, for: peerID)
}
private func formatFingerprint(_ fingerprint: String) -> String {

View File

@ -14,18 +14,25 @@ struct MacImagePickerView: View {
let completion: (URL?) -> Void
@Environment(\.dismiss) private var dismiss
private enum Strings {
static let title: LocalizedStringKey = "mac.image_picker.title"
static let select = String(localized: "mac.image_picker.select", comment: "Button that opens the macOS open-panel to pick an image")
static let panelMessage = String(localized: "mac.image_picker.panel_message", comment: "Message shown in the macOS NSOpenPanel when picking an image")
static let cancel = String(localized: "mac.image_picker.cancel", comment: "Cancel button for the macOS image picker sheet")
}
var body: some View {
VStack(spacing: 16) {
Text("Choose an image")
Text(Strings.title)
.font(.headline)
Button("Select Image") {
Button(Strings.select) {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
panel.message = "Choose an image to send"
panel.message = Strings.panelMessage
if panel.runModal() == .OK {
completion(panel.url)
@ -35,7 +42,7 @@ struct MacImagePickerView: View {
}
.buttonStyle(.borderedProminent)
Button("Cancel") {
Button(Strings.cancel) {
completion(nil)
}
.buttonStyle(.bordered)

View File

@ -12,6 +12,10 @@ struct LocationChannelsSheet: View {
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
/// Geohash waiting on the fine-precision OpSec confirmation before share.
@State private var pendingShareGeohash: String?
@State private var showSharePrecisionWarning = false
@State private var activeSharePayload: ChannelSharePayload?
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
@ -24,10 +28,30 @@ struct LocationChannelsSheet: View {
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region")
static func quickJoinDescription(_ regionName: String) -> String {
String(
format: String(localized: "location_channels.quick_join.description", defaultValue: "the region channel where people from %@ tend to gather — the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks.", comment: "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention"),
locale: .current,
regionName
)
}
static func quickJoinLabel(_ regionName: String) -> String {
String(
format: String(localized: "location_channels.quick_join.join_label", defaultValue: "join the %@ region channel", comment: "Accessibility label for the quick join row; %@ is the localized country/region name"),
locale: .current,
regionName
)
}
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static let shareChannel = String(localized: "channel.share.action", defaultValue: "share channel", comment: "Context-menu / accessibility action that shares a location-channel invite")
static let sharePrecisionTitle = String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite")
static let sharePrecisionMessage = String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite")
static let shareAnyway = String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
@ -163,6 +187,39 @@ struct LocationChannelsSheet: View {
}
}
.onChange(of: locationChannelsModel.availableChannels) { _ in }
.confirmationDialog(
Strings.sharePrecisionTitle,
isPresented: $showSharePrecisionWarning,
titleVisibility: .visible
) {
Button(Strings.shareAnyway) {
if let gh = pendingShareGeohash {
presentShare(forGeohash: gh)
}
pendingShareGeohash = nil
}
Button("common.cancel", role: .cancel) {
pendingShareGeohash = nil
}
} message: {
Text(Strings.sharePrecisionMessage)
}
.sheet(item: $activeSharePayload) { payload in
ShareActivityView(text: payload.text)
}
}
private func requestShare(forGeohash geohash: String) {
if ChannelShare.shouldWarn(forGeohash: geohash) {
pendingShareGeohash = geohash
showSharePrecisionWarning = true
} else {
presentShare(forGeohash: geohash)
}
}
private func presentShare(forGeohash geohash: String) {
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
}
private var closeButton: some View {
@ -204,12 +261,21 @@ struct LocationChannelsSheet: View {
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) },
shareGeohash: channel.geohash,
onShare: { requestShare(forGeohash: channel.geohash) }
) {
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.contextMenu {
Button {
requestShare(forGeohash: channel.geohash)
} label: {
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
}
}
.padding(.vertical, 6)
}
} else if locationChannelsModel.permissionState == .authorized {
@ -236,6 +302,12 @@ struct LocationChannelsSheet: View {
customTeleportSection
.padding(.vertical, 8)
if QuickJoinSuggestion.current() != nil {
sectionDivider
quickJoinSection
.padding(.vertical, 8)
}
let bookmarkedList = locationChannelsModel.bookmarks
if !bookmarkedList.isEmpty {
sectionDivider
@ -319,6 +391,46 @@ struct LocationChannelsSheet: View {
}
}
/// One tap into the region channel around the device region's main
/// population center derived from the locale, no location access, no
/// roster (see QuickJoinSuggestion). The caption is deliberately blunt
/// that the cell is public and watched: discovery, not circumvention.
@ViewBuilder
private var quickJoinSection: some View {
if let suggestion = QuickJoinSuggestion.current() {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.quickJoinTitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(action: {
locationChannelsModel.teleport(to: suggestion.geohash)
isPresented = false
}) {
HStack {
Text(verbatim: "\(suggestion.flag) \(suggestion.localizedName)")
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
Spacer()
Text(verbatim: "#\(suggestion.geohash)")
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.quickJoinLabel(suggestion.localizedName))
.accessibilityHint(Strings.switchChannelHint)
Text(Strings.quickJoinDescription(suggestion.localizedName))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
@ -347,7 +459,9 @@ struct LocationChannelsSheet: View {
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
accessoryAction: { locationChannelsModel.toggleBookmark(gh) },
shareGeohash: gh,
onShare: { requestShare(forGeohash: gh) }
) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@ -358,6 +472,13 @@ struct LocationChannelsSheet: View {
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.contextMenu {
Button {
requestShare(forGeohash: gh)
} label: {
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
}
}
.padding(.vertical, 6)
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
@ -391,6 +512,8 @@ struct LocationChannelsSheet: View {
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
shareGeohash: String? = nil,
onShare: (() -> Void)? = nil,
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
@ -438,6 +561,9 @@ struct LocationChannelsSheet: View {
if let accessoryActionTitle, let accessoryAction {
Button(accessoryActionTitle, action: accessoryAction)
}
if shareGeohash != nil, let onShare {
Button(Strings.shareChannel, action: onShare)
}
}
}

View File

@ -20,7 +20,7 @@ struct MediaMessageView: View {
/// is a reference type mutated in place, and SwiftUI compares reference
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
private let deliveryStatus: DeliveryStatus
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL?
@ -48,6 +48,15 @@ struct MediaMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if conversationUIModel.showsVerifiedSeal(for: message) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.green.opacity(0.85))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
@ -57,11 +66,11 @@ struct MediaMessageView: View {
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
deliveryStatus != .notSentYet {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
DeliveryStatusView(status: deliveryStatus)
.padding(.leading, 4)
.contentShape(Rectangle())
}
@ -75,14 +84,14 @@ struct MediaMessageView: View {
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
deliveryStatus != .notSentYet {
if case .failed = deliveryStatus {
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
@ -132,26 +141,24 @@ struct MediaMessageView: View {
}
}
private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
private func mediaSendState(for deliveryStatus: DeliveryStatus, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
// A received message is never in a send state: BitchatMessage defaults
// private messages to .sending, so an incoming message's status must
// not drive the reveal mask or disable the reveal tap.
guard isFromMe else { return (false, nil, false) }
var isSending = false
var progress: Double?
if let status = deliveryStatus {
switch status {
case .sending:
switch deliveryStatus {
case .sending:
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .carried, .read, .delivered, .failed:
break
progress = Double(reached) / Double(total)
}
case .notSentYet, .sent, .carried, .read, .delivered, .failed:
break
}
let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message)
let clamped = progress.map { max(0, min(1, $0)) }

View File

@ -430,7 +430,7 @@ private extension MessageListView {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
case .failed = message.deliveryStatus
else { return false }
return true
}

View File

@ -0,0 +1,58 @@
//
// ShareActivityView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// Hosts the system share UI after an optional OpSec confirmation (#1497).
struct ShareActivityView: View {
let text: String
@Environment(\.dismiss) private var dismiss
var body: some View {
#if os(iOS)
ShareActivityController(items: [text])
.ignoresSafeArea()
#elseif os(macOS)
VStack(alignment: .leading, spacing: 16) {
Text(text)
.font(.body)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
Spacer()
ShareLink(item: text) {
Label(
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Button that opens the system share sheet for a location channel invite"),
systemImage: "square.and.arrow.up"
)
}
Button(String(localized: "common.done", defaultValue: "done", comment: "Dismisses a sheet")) {
dismiss()
}
.keyboardShortcut(.cancelAction)
}
}
.padding()
.frame(minWidth: 360)
#endif
}
}
#if os(iOS)
import UIKit
private struct ShareActivityController: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#endif

View File

@ -1,6 +1,7 @@
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
import AVFoundation
#if os(iOS)
import UIKit
#else
@ -109,19 +110,27 @@ struct ImageWrapper: View {
}
}
/// Placeholder scanner UI; real camera scanning will be added later.
/// Peer verification QR scanner. Uses the camera on iOS and macOS; macOS also
/// keeps a paste/validate fallback for machines without a usable camera.
struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
@State private var result: String = "" // not shown for iOS scanner
@State private var result: String = ""
@State private var lastValid: String = ""
@State private var cameraUnavailable = false
private enum Strings {
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
static let validate: LocalizedStringKey = "verification.scan.validate"
static let cameraUnavailable = String(
localized: "verification.scan.camera_unavailable",
defaultValue: "Camera unavailable — paste a QR below.",
comment: "Shown over the scanner preview when no camera is available or permission was denied"
)
static func requested(_ nickname: String) -> String {
String(
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
@ -135,69 +144,83 @@ struct QRScanView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
#if os(iOS)
CameraScannerView(isActive: isActive) { code in
// Deduplicate: ignore if we just processed this exact QR code
guard code != lastValid else { return }
switch verificationModel.verifyScannedPayload(code) {
case .requested:
// Successfully initiated verification; remember this QR to prevent re-scanning
lastValid = code
// Close scanner and return to "My QR" view
onSuccess?()
case .notFound, .invalid:
// Ignore invalid/no-match reads and keep scanning
break
ZStack {
CameraScannerView(isActive: isActive, onUnavailable: { cameraUnavailable = true }) { code in
handleScannedCode(code, announceResult: false)
}
if cameraUnavailable {
Text(Strings.cameraUnavailable)
.bitchatFont(size: 13, weight: .medium)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
.padding(16)
}
}
.frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
#if os(macOS)
Text(Strings.pastePrompt)
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(palette.secondary.opacity(0.4))
Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else {
result = Strings.requested("") // Already processed
return
}
switch verificationModel.verifyScannedPayload(input) {
case .requested(let nickname):
result = Strings.requested(nickname)
lastValid = input
// Close scanner and return to "My QR" view
onSuccess?()
case .notFound:
result = Strings.notFound
case .invalid:
result = Strings.invalid
}
handleScannedCode(input, announceResult: true)
}
.buttonStyle(.bordered)
if !result.isEmpty {
Text(result)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
}
#endif
// No status text under camera per design
Spacer()
}
.padding()
}
private func handleScannedCode(_ code: String, announceResult: Bool) {
guard code != lastValid else {
if announceResult {
result = Strings.requested("")
}
return
}
switch verificationModel.verifyScannedPayload(code) {
case .requested(let nickname):
lastValid = code
if announceResult {
result = Strings.requested(nickname)
}
onSuccess?()
case .notFound:
if announceResult {
result = Strings.notFound
}
case .invalid:
if announceResult {
result = Strings.invalid
}
}
}
}
#if os(iOS)
import AVFoundation
struct CameraScannerView: UIViewRepresentable {
typealias UIViewType = PreviewView
var isActive: Bool
var onUnavailable: (() -> Void)? = nil
var onCode: (String) -> Void
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(sessionOwner: view, onCode: onCode)
context.coordinator.setup(
previewLayer: view.videoPreviewLayer,
onCode: onCode,
onUnavailable: onUnavailable
)
context.coordinator.setActive(isActive)
return view
}
@ -206,68 +229,7 @@ struct CameraScannerView: UIViewRepresentable {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private weak var owner: PreviewView?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
self.owner = sessionOwner
self.onCode = onCode
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
sessionOwner.videoPreviewLayer.session = session
// Request permission and start
AVCaptureDevice.requestAccess(for: .video) { granted in
self.permissionGranted = granted
if granted && self.desiredActive && !self.isRunning {
self.setActive(true)
}
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
final class PreviewView: UIView {
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
@ -279,8 +241,166 @@ struct CameraScannerView: UIViewRepresentable {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
#elseif os(macOS)
struct CameraScannerView: NSViewRepresentable {
typealias NSViewType = PreviewView
var isActive: Bool
var onUnavailable: (() -> Void)? = nil
var onCode: (String) -> Void
func makeNSView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(
previewLayer: view.videoPreviewLayer,
onCode: onCode,
onUnavailable: onUnavailable
)
context.coordinator.setActive(isActive)
return view
}
func updateNSView(_ nsView: PreviewView, context: Context) {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
final class PreviewView: NSView {
let videoPreviewLayer = AVCaptureVideoPreviewLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
videoPreviewLayer.videoGravity = .resizeAspectFill
layer = CALayer()
layer?.addSublayer(videoPreviewLayer)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layout() {
super.layout()
videoPreviewLayer.frame = bounds
}
}
}
#endif
final class CameraScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private var onUnavailable: (() -> Void)?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
private var didConfigureSession = false
private weak var previewLayer: AVCaptureVideoPreviewLayer?
func setup(
previewLayer: AVCaptureVideoPreviewLayer,
onCode: @escaping (String) -> Void,
onUnavailable: (() -> Void)? = nil
) {
self.onCode = onCode
self.onUnavailable = onUnavailable
self.previewLayer = previewLayer
previewLayer.session = session
// Check authorization before creating AVCaptureDeviceInput so tests and
// cold launches do not trigger a TCC prompt just by constructing input.
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
permissionGranted = true
if !configureSessionIfNeeded() {
reportUnavailable()
}
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
self.permissionGranted = granted
if granted {
if !self.configureSessionIfNeeded() {
self.reportUnavailable()
return
}
if self.desiredActive && !self.isRunning {
self.setActive(true)
}
} else {
self.reportUnavailable()
}
}
}
default:
permissionGranted = false
reportUnavailable()
}
}
@discardableResult
private func configureSessionIfNeeded() -> Bool {
guard !didConfigureSession else { return true }
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
return false
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
session.commitConfiguration()
return false
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
previewLayer?.session = session
didConfigureSession = true
return true
}
private func reportUnavailable() {
DispatchQueue.main.async {
self.onUnavailable?()
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted, didConfigureSession else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ -320,19 +440,12 @@ struct VerificationSheetView: View {
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
#if os(iOS)
QRScanView(isActive: showingScanner, onSuccess: {
showingScanner = false
})
.environmentObject(verificationModel)
.frame(height: 280)
.frame(minHeight: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
#else
QRScanView(onSuccess: {
showingScanner = false
})
.environmentObject(verificationModel)
#endif
}
.padding()
.frame(maxWidth: .infinity)

View File

@ -10,6 +10,8 @@
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.personal-information.location</key>

View File

@ -13,6 +13,58 @@ import BitFoundation
struct BLEServiceCoreTests {
/// Records ping completions (delivered on the main actor) so the
/// injected-clock test can assert from its own thread.
private final class MeshPingResultCollector: @unchecked Sendable {
private let lock = NSLock()
private var recorded: [MeshPingResult?] = []
var results: [MeshPingResult?] { lock.withLock { recorded } }
func record(_ result: MeshPingResult?) {
lock.withLock { recorded.append(result) }
}
}
/// The ping deadline asserted on an injected clock: the real 10s
/// product constant, no wall-clock in the loop. This is the pattern
/// for every engine deadline the timeout must not fire early, must
/// fire exactly once at the deadline, and must stay consumed after.
@Test
func meshPingTimesOutOnTheInjectedClockExactlyOnce() async throws {
let scheduler = BLEEngineManualScheduler()
let ble = makeService(engineScheduler: scheduler)
let peer = PeerID(str: "aabbccdd00112233")
ble._test_seedConnectedPeer(peer, nickname: "Alice")
let collector = MeshPingResultCollector()
ble.sendMeshPing(to: peer) { result in
collector.record(result)
}
// The probe registers and its deadline schedules on the engine;
// fence that submission before touching the clock.
await ble._test_drainNoiseMessagePipeline()
#expect(scheduler.pendingCount == 1)
// A hair before the deadline nothing may fire.
scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds - 0.01)
await ble._test_drainNoiseMessagePipeline()
#expect(collector.results.isEmpty)
// Crossing the deadline expires the probe: nil, exactly once, on
// the main actor.
scheduler.advance(by: 0.02)
let completed = await TestHelpers.waitUntil(
{ collector.results.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(completed)
#expect(collector.results == [nil])
// The deadline is consumed more time cannot re-fire it.
scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds * 2)
await ble._test_drainNoiseMessagePipeline()
#expect(collector.results.count == 1)
}
@Test
func duplicatePacket_isDeduped() async throws {
let ble = makeService()
@ -39,7 +91,7 @@ struct BLEServiceCoreTests {
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!receivedDuplicate)
@ -117,7 +169,7 @@ struct BLEServiceCoreTests {
let unsignedRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!unsignedRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
@ -133,7 +185,7 @@ struct BLEServiceCoreTests {
let badSignatureRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!badSignatureRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
@ -502,26 +554,19 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
let rebindGate = VerifiedDirectRebindGate()
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
defer {
rebindGate.release()
ble._test_afterVerifiedDirectRebindEnqueued = nil
}
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
// The rebind, its Noise-proof retirement, and the ordinary
// reconnect preparation are one engine slot: no observer can see
// the new binding while the victim's stale sending keys are still
// available. Once the binding is visible, the keys must already be
// gone.
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
timeout: TestConstants.longTimeout
)
try #require(announcePaused)
// Rebind and ordinary reconnect preparation are one bleQueue
// critical section. Once the binding is visible, stale sending keys
// must already be unavailable.
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
try #require(rebound)
#expect(!ble.canDeliverSecurely(to: victimPeerID))
rebindGate.release()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
@ -908,6 +953,17 @@ struct BLEServiceCoreTests {
// old generation the remote may no longer be able to read.
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// The capability-proof watchdog armed at the original authentication
// is still live and can genuinely reach its real 5s deadline here on
// a stalled CI runner. Fire it deterministically: its drain must
// respect the deferred-until-convergence state instead of encrypting
// the parked queues under the restored keys (the exact silent loss
// the defer path exists to prevent). The retry below then still
// finds the queues parked.
ble._test_forcePrivateMediaProofTimeout(for: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// Release the mandatory convergence retry: it retires the restored
// session and starts a fresh XX exchange with the live peer.
recoveryGate.release()
@ -1209,7 +1265,7 @@ struct BLEServiceCoreTests {
let didObservePanicClosure = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let didObserveClosure = panicIngressObserver.waitUntilClosed(
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
gate.release()
continuation.resume(returning: didObserveClosure)
@ -1340,7 +1396,7 @@ struct BLEServiceCoreTests {
// rotated sender IDs never bought a sixth response.
let exceededBudget = await TestHelpers.waitUntil(
{ outbound.count(ofType: .pong) > budget },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!exceededBudget)
#expect(outbound.count(ofType: .pong) == budget)
@ -1406,35 +1462,6 @@ private final class SessionReconcileCounter: @unchecked Sendable {
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class ReceivePacketHandoffGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
@ -1499,7 +1526,8 @@ private final class PanicIngressObserver: @unchecked Sendable {
private func makeService(
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler()
) -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
@ -1509,7 +1537,8 @@ private func makeService(
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false,
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout,
engineScheduler: engineScheduler
)
}

View File

@ -0,0 +1,26 @@
//
// ChannelShareTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
@testable import bitchat
struct ChannelShareTests {
@Test func payloadIncludesGeohashDeepLinkAndStoreURL() {
let text = ChannelShare.payload(forGeohash: "u4pru")
#expect(text.contains("#u4pru"))
#expect(text.contains("bitchat://geohash/u4pru"))
#expect(text.contains(ChannelShare.appStoreURL))
#expect(!text.lowercased().contains("i'm in"))
}
@Test func precisionWarningStartsAtNeighborhood() {
#expect(!ChannelShare.shouldWarn(forGeohash: "u4pru")) // city = 5
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruy")) // neighborhood = 6
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruyzd"))
}
}

View File

@ -52,9 +52,19 @@ private final class MockChatComposerContext: ChatComposerContext {
var activeChannel: ChannelID = .mesh
var meshNickname = "me"
var meshNicknamesByPeerID: [PeerID: String] = [:]
var blockedMeshNicknames: Set<String> = []
var blockedNostrPubkeys: Set<String> = []
func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID }
func isMeshNicknameBlocked(_ nickname: String) -> Bool {
blockedMeshNicknames.contains(nickname)
}
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
}
// Geohash identity
var geoNicknames: [String: String] = [:]
static let dummyIdentity = NostrIdentity(
@ -120,6 +130,34 @@ struct ChatComposerCoordinatorContextTests {
#expect(context.queriedPeerCandidates == [["carol#dddd"]])
}
@Test @MainActor
func updateAutocomplete_excludesBlockedMeshAndGeohashPeers() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "eve",
PeerID(str: "3333333333333333"): "me"
]
context.blockedMeshNicknames = ["eve"]
context.queryResult = (["@alice"], NSRange(location: 0, length: 3))
coordinator.updateAutocomplete(for: "@a", cursorPosition: 2)
#expect(context.queriedPeerCandidates == [["alice"]])
let geoContext = MockChatComposerContext()
let geoCoordinator = ChatComposerCoordinator(context: geoContext)
geoContext.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq"))
geoContext.geoNicknames = [
"aaaabbbbccccdddd": "carol",
"bbbbccccddddeeee": "blocked"
]
geoContext.blockedNostrPubkeys = ["bbbbccccddddeeee"]
geoCoordinator.updateAutocomplete(for: "@", cursorPosition: 1)
#expect(geoContext.queriedPeerCandidates == [["carol#dddd"]])
}
@Test @MainActor
func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() {
let context = MockChatComposerContext()

View File

@ -425,6 +425,10 @@ struct ChatPeerIdentityCoordinatorContextTests {
)
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
// Local alias outranks a live mesh announce for the same peer.
context.nicknamesByPeerID[identityPeer] = "bob"
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
#expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed")
#expect(coordinator.getMyFingerprint() == "my-fingerprint")
}

View File

@ -147,6 +147,10 @@ struct ChatViewModelDeliveryStatusTests {
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
// ...but a retry after a real failure stays visible.
#expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending))
// .notSentYet is the pre-transport initial state: leaving it is always
// allowed, returning to it never is.
#expect(!Conversation.shouldSkipStatusUpdate(current: .notSentYet, new: .sending))
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .notSentYet))
}
@Test @MainActor
@ -729,9 +733,10 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < carried < partiallyDelivered < delivered < read
// notSentYet < failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.notSentYet,
.failed(reason: "test"),
.sending,
.sent,
@ -745,13 +750,14 @@ struct ChatViewModelDeliveryStatusTests {
// This is more of a documentation test to ensure the ranking logic is understood
for (index, status) in statuses.enumerated() {
switch status {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .carried: #expect(index == 3)
case .partiallyDelivered: #expect(index == 4)
case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
case .notSentYet: #expect(index == 0)
case .failed: #expect(index == 1)
case .sending: #expect(index == 2)
case .sent: #expect(index == 3)
case .carried: #expect(index == 4)
case .partiallyDelivered: #expect(index == 5)
case .delivered: #expect(index == 6)
case .read: #expect(index == 7)
}
}
}

View File

@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests {
transport.simulateConnect(peerID, nickname: "alice")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didResolve)
// Action: User types /msg command
viewModel.sendMessage("/msg @alice Hello Private World")
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didSend)
// Assert:
@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests {
transport.simulateConnect(peerID, nickname: "troll")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didResolve)
// Action
@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests {
// Assert
// Verify identity manager was called to block "fingerprint_123"
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didBlock)
}
@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests {
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
// Assert
@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests {
{
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
// Assert

View File

@ -321,7 +321,7 @@ struct ChatViewModelCommandTests {
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.negativeWaitWindow)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
@ -422,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests {
transport.sentReadReceipts.contains {
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
}
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.negativeWaitWindow)
#expect(sentReadReceipt)
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
@ -506,7 +506,7 @@ struct ChatViewModelReceivingTests {
let found = await TestHelpers.waitUntil({
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(found)
}
@ -535,11 +535,11 @@ struct ChatViewModelNoisePayloadTests {
let stored = await TestHelpers.waitUntil({
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
let acked = await TestHelpers.waitUntil({
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(stored)
#expect(acked)
@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests {
return name == "Bob"
}
return false
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(delivered)
}
@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
let conversationStoreUpdated = await TestHelpers.waitUntil({
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
@ -626,7 +626,7 @@ struct ChatViewModelNoisePayloadTests {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(privateChatUpdated)
#expect(conversationStoreUpdated)
@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests {
let bound = await TestHelpers.waitUntil({
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(bound)
let qr = VerificationService.VerificationQR(
@ -982,7 +982,7 @@ struct ChatViewModelPeerTests {
let cleaned = await TestHelpers.waitUntil({
!viewModel.unreadPrivateMessages.contains(stalePeer)
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(cleaned)
}

View File

@ -142,7 +142,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -151,7 +151,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -161,7 +161,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
@ -169,7 +169,7 @@ struct CourierEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(handedOver)
// With CoreBluetooth disabled there is no physical link for the send
@ -183,7 +183,7 @@ struct CourierEndToEndTests {
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(received)
@ -229,7 +229,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -237,7 +237,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -245,7 +245,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
@ -253,7 +253,7 @@ struct CourierEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
@ -265,7 +265,7 @@ struct CourierEndToEndTests {
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!delivered)
}
@ -293,7 +293,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -301,7 +301,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -310,7 +310,7 @@ struct CourierEndToEndTests {
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!leakedOnUnverifiedAnnounce)
#expect(!carol.courierStore.isEmpty)
@ -318,7 +318,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(announced)
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
@ -326,7 +326,7 @@ struct CourierEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(handedOver)
#expect(!carol.courierStore.isEmpty)
@ -355,7 +355,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -363,14 +363,14 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announced)
let directAnnounce = try #require(bobOut.first(ofType: .announce))
@ -385,7 +385,7 @@ struct CourierEndToEndTests {
let remoteHandover = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(remoteHandover)
#expect(!carol.courierStore.isEmpty)
@ -398,7 +398,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(reannounced)
let freshAnnounce = try #require(
@ -410,7 +410,7 @@ struct CourierEndToEndTests {
let refloodedInCooldown = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!refloodedInCooldown)
#expect(!carol.courierStore.isEmpty)
@ -424,7 +424,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announcedAgain = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announcedAgain)
let directAgain = try #require(
@ -434,7 +434,7 @@ struct CourierEndToEndTests {
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!handedOverWithoutLinkProof)
#expect(!carol.courierStore.isEmpty)
@ -457,7 +457,7 @@ struct CourierEndToEndTests {
let queuedPacket = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!queuedPacket)
}
@ -494,7 +494,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!stored)
}
@ -532,7 +532,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!stored)
}
@ -575,7 +575,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!stored)
}
@ -602,14 +602,14 @@ struct CourierEndToEndTests {
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(delivered)
// Give a duplicate delivery a chance to surface, then confirm the
// second copy never reached the delegate.
let duplicated = await TestHelpers.waitUntil(
{ bobDelegate.snapshot().count > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!duplicated)
#expect(bobDelegate.snapshot().count == 1)
@ -629,7 +629,7 @@ struct CourierEndToEndTests {
let initiated = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!initiated)
@ -639,7 +639,7 @@ struct CourierEndToEndTests {
ble.sendDeliveryAck(for: "msg-2", to: present)
let initiatedForPresent = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) > 0 },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(initiatedForPresent)
}
@ -669,7 +669,7 @@ struct CourierEndToEndTests {
/// Minimal transport stub for exercising MessageRouter's courier deposit
/// logic without BLE plumbing.
private final class CourierCaptureTransport: Transport {
private final class CourierCaptureTransport: Transport, MeshCourierTransporting {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?

View File

@ -87,7 +87,7 @@ struct PrekeyEndToEndTests {
peer.sendBroadcastAnnounce()
let published = await TestHelpers.waitUntil(
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(published)
return (
@ -124,7 +124,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(cached)
@ -138,7 +138,7 @@ struct PrekeyEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -149,7 +149,7 @@ struct PrekeyEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -158,7 +158,7 @@ struct PrekeyEndToEndTests {
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(reannounced)
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
@ -166,7 +166,7 @@ struct PrekeyEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
@ -178,7 +178,7 @@ struct PrekeyEndToEndTests {
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(received)
@ -207,7 +207,7 @@ struct PrekeyEndToEndTests {
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
let redelivered = await TestHelpers.waitUntil(
{ bobDelegate.snapshot().count == 2 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!redelivered)
#expect(bobDelegate.snapshot().count == 1)
@ -235,7 +235,7 @@ struct PrekeyEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -248,7 +248,7 @@ struct PrekeyEndToEndTests {
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(received)
let delivered = try #require(bobDelegate.snapshot().first)
@ -272,7 +272,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
}
@ -310,7 +310,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
}
@ -328,7 +328,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(cached)
// The verified bundle now participates in Alice's sync rounds.
@ -364,7 +364,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
@ -396,7 +396,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))

View File

@ -204,7 +204,8 @@ struct PrivateMediaEndToEndTests {
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: deniedID
transferId: deniedID,
allowLegacyFallback: false
)
let denied = await TestHelpers.waitUntil(
{ cancellations.contains(deniedID) },
@ -254,7 +255,7 @@ struct PrivateMediaEndToEndTests {
// Consent is invocation-scoped, not a sticky peer preference.
let retryID = "legacy-retry-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID, allowLegacyFallback: false)
let retryDenied = await TestHelpers.waitUntil(
{ cancellations.contains(retryID) },
timeout: TestConstants.longTimeout
@ -998,7 +999,7 @@ struct PrivateMediaEndToEndTests {
let encryptedID = "encrypted-over-256-\(UUID().uuidString)"
let legacyID = "legacy-over-256-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID, allowLegacyFallback: false)
alice.sendFilePrivate(
file,
to: oldCarol.myPeerID,
@ -1318,7 +1319,7 @@ struct PrivateMediaEndToEndTests {
mimeType: mimeType,
content: content
)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)")
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)", allowLegacyFallback: false)
let fragmented = await TestHelpers.waitUntil(
{ tap.hasCompleteFragmentTrain },

View File

@ -50,7 +50,7 @@ struct PublicChatE2ETests {
var bobReceivedMessage = false
var charlieReceivedMessage = false
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
await confirmation("Both receive message", expectedCount: 2) { receiveMessage in
bob.messageDeliveryHandler = { message in
if message.content == TestConstants.testMessage1 {
if !bobReceivedMessage {

View File

@ -37,7 +37,7 @@ struct GossipSyncManagerTests {
}
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout)
}
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
@ -394,7 +394,7 @@ struct GossipSyncManagerTests {
)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout)
// Barrier: flush the sync queue so a late third packet would be visible.
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
@ -477,7 +477,7 @@ struct GossipSyncManagerTests {
manager.handleRequestSync(from: peer, request: request)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout)
// Barrier: both requests have been processed once this returns.
manager._performMaintenanceSynchronously(now: Date())
#expect(delegate.packets.count == 1)
@ -498,7 +498,7 @@ struct GossipSyncManagerTests {
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let packet = try #require(delegate.packets.first)
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
let types = try #require(request.types)
@ -553,7 +553,7 @@ struct GossipSyncManagerTests {
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
@ -615,7 +615,7 @@ struct GossipSyncManagerTests {
)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
// Barrier: flush the sync queue so a late second packet would be visible.
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
@ -641,7 +641,7 @@ struct GossipSyncManagerTests {
let stalledID = try #require(Data(hexString: "0102030405060708"))
manager.requestMissingFragments(fragmentIDs: [stalledID])
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.requestSync.rawValue)
#expect(sent.ttl == 0)
@ -697,7 +697,7 @@ struct GossipSyncManagerTests {
// And a .prekeyBundle sync request is answered with the stored packet.
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let served = try #require(delegate.packets.first)
#expect(served.type == MessageType.prekeyBundle.rawValue)
#expect(served.isRSR)
@ -774,7 +774,7 @@ struct GossipSyncManagerTests {
)
let restored = await TestHelpers.waitUntil(
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.settleTimeout
)
#expect(restored)
}
@ -844,7 +844,7 @@ struct GossipSyncManagerTests {
!FileManager.default.fileExists(atPath: fileURL.path)
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
},
timeout: TestConstants.shortTimeout
timeout: TestConstants.settleTimeout
)
#expect(erased)
}

View File

@ -0,0 +1,49 @@
import Foundation
@testable import bitchat
/// Manually advanced engine scheduler: deferred work runs when the test
/// advances the clock past its deadline, on the real engine queue (deferred
/// bodies touch engine-confined state), and `advance` returns only after
/// the released work has finished so assertions that follow observe its
/// engine-side effects without polling.
final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable {
private let lock = NSLock()
private var engineQueue: DispatchQueue?
private var now: TimeInterval = 0
private var pending: [(deadline: TimeInterval, work: DispatchWorkItem)] = []
func activate(engineQueue: DispatchQueue) {
lock.withLock { self.engineQueue = engineQueue }
}
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
lock.withLock { pending.append((now + delay, work)) }
}
var pendingCount: Int {
lock.withLock { pending.count }
}
/// Advances the clock, releasing due work in deadline order.
/// Cancellation keeps its production semantics: dispatch skips a
/// cancelled `DispatchWorkItem` at execution.
func advance(by interval: TimeInterval) {
let (due, queue): ([DispatchWorkItem], DispatchQueue?) = lock.withLock {
now += interval
let cutoff = now
let released = pending
.filter { $0.deadline <= cutoff }
.sorted { $0.deadline < $1.deadline }
.map(\.work)
pending.removeAll { $0.deadline <= cutoff }
return (released, engineQueue)
}
guard let queue else { return }
for work in due {
queue.async(execute: work)
}
// Fence: released work (and anything it enqueued) has run before
// the test's next assertion.
queue.sync {}
}
}

View File

@ -14,7 +14,10 @@ import BitFoundation
/// Mock Transport implementation for testing ChatViewModel in isolation.
/// Records all method calls and allows test code to verify interactions.
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
final class MockTransport: Transport, PrivateMediaDeletionPersisting,
MeshFileTransferring, MeshVerifying, MeshCourierTransporting,
MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming,
MeshGroupMessaging, MeshBoardBroadcasting {
// MARK: - Protocol Properties
@ -205,11 +208,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
sentBroadcastFiles.append((packet, transferId))
}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
@ -242,6 +240,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
cancelledTransfers.append(transferId)
}
private(set) var sentFileReceiptRetries: [(BitchatFilePacket, PeerID, String)] = []
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
sentFileReceiptRetries.append((packet, peerID, transferId))
}
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
@ -317,6 +324,50 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
meshTopologySnapshot
}
// MARK: - Remaining mesh capabilities (recording stubs)
private(set) var sentVouchAttestations: [(Data, PeerID)] = []
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sentVouchAttestations.append((payload, peerID))
}
var archivedPublicMessages: [ArchivedPublicMessage] = []
private(set) var purgedAllArchived = false
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
let archived = archivedPublicMessages
Task { @MainActor in completion(archived) }
}
func purgeAllArchivedPublicMessages() {
purgedAllArchived = true
}
private(set) var sentVoiceFrames: [(Data, PeerID)] = []
private(set) var sentVoiceBroadcasts: [Data] = []
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
sentVoiceFrames.append((burstContent, peerID))
}
func sendVoiceFrameBroadcast(_ burstContent: Data) {
sentVoiceBroadcasts.append(burstContent)
}
private(set) var sentGroupInvites: [(Data, PeerID)] = []
private(set) var sentGroupKeyUpdates: [(Data, PeerID)] = []
private(set) var broadcastGroupMessages: [Data] = []
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
sentGroupInvites.append((statePayload, peerID))
}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
sentGroupKeyUpdates.append((statePayload, peerID))
}
func broadcastGroupMessage(_ envelope: Data) {
broadcastGroupMessages.append(envelope)
}
private(set) var sentBoardPayloads: [Data] = []
func sendBoardPayload(_ payload: Data) {
sentBoardPayloads.append(payload)
}
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions

View File

@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase {
}
private func waitUntil(
timeout: TimeInterval = 1.0,
timeout: TimeInterval = TestConstants.settleTimeout,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)

Some files were not shown because too many files have changed in this diff Show More