mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Merge remote-tracking branch 'origin/main' into fix/1064-default-view
# Conflicts: # bitchat/App/AppRuntime.swift
This commit is contained in:
commit
159b688b09
@ -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"]}}
|
||||
4
Justfile
4
Justfile
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
13
bitchat.xcodeproj/project.pbxproj
generated
13
bitchat.xcodeproj/project.pbxproj
generated
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)" }
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
39
bitchat/Services/BLE/BLEEngineScheduler.swift
Normal file
39
bitchat/Services/BLE/BLEEngineScheduler.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
115
bitchat/Services/BLE/BLELinkAuthState.swift
Normal file
115
bitchat/Services/BLE/BLELinkAuthState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
152
bitchat/Services/BLE/BLELinkBindings.swift
Normal file
152
bitchat/Services/BLE/BLELinkBindings.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Identity↔link 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()
|
||||
}
|
||||
}
|
||||
40
bitchat/Services/BLE/BLELinkEvent.swift
Normal file
40
bitchat/Services/BLE/BLELinkEvent.swift
Normal 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)
|
||||
}
|
||||
@ -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 identity↔link 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.
|
||||
/// Identity↔link 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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
bitchat/Services/BLE/BLEMeshPingTracker.swift
Normal file
62
bitchat/Services/BLE/BLEMeshPingTracker.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
84
bitchat/Services/BLE/BLEPeerRegistryStore.swift
Normal file
84
bitchat/Services/BLE/BLEPeerRegistryStore.swift
Normal 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(®istry) }
|
||||
}
|
||||
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
363
bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift
Normal file
363
bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
411
bitchat/Services/BLE/BLERadioController.swift
Normal file
411
bitchat/Services/BLE/BLERadioController.swift
Normal 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 background→foreground 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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] = []
|
||||
|
||||
|
||||
@ -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 retire↔reconnect 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
|
||||
}
|
||||
|
||||
433
bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift
Normal file
433
bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift
Normal 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
|
||||
}
|
||||
}
|
||||
320
bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Normal file
320
bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Normal 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
@ -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.
|
||||
|
||||
49
bitchat/Services/ChannelShare.swift
Normal file
49
bitchat/Services/ChannelShare.swift
Normal 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
|
||||
}
|
||||
@ -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 —
|
||||
|
||||
152
bitchat/Services/MeshTransportCapabilities.swift
Normal file
152
bitchat/Services/MeshTransportCapabilities.swift
Normal 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 }
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 fingerprint↔peerID 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
113
bitchat/Utils/QuickJoinRegions.swift
Normal file
113
bitchat/Utils/QuickJoinRegions.swift
Normal 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",
|
||||
]
|
||||
}
|
||||
36
bitchat/Utils/SafeRegex.swift
Normal file
36
bitchat/Utils/SafeRegex.swift
Normal 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()
|
||||
}()
|
||||
}
|
||||
@ -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: "")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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("#"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)) }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
58
bitchat/Views/ShareActivityView.swift
Normal file
58
bitchat/Views/ShareActivityView.swift
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
26
bitchatTests/ChannelShareTests.swift
Normal file
26
bitchatTests/ChannelShareTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
49
bitchatTests/Mocks/BLEEngineManualScheduler.swift
Normal file
49
bitchatTests/Mocks/BLEEngineManualScheduler.swift
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user