mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Merge remote-tracking branch 'origin/main' into harden/radio-metadata
# Conflicts: # bitchat/Services/BLE/BLEService.swift
This commit is contained in:
commit
4a2e06bac0
@ -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 */
|
||||
|
||||
@ -85,7 +85,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,18 +152,23 @@ final class AppRuntime: ObservableObject {
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
expireAgedMedia()
|
||||
performMediaMaintenance()
|
||||
|
||||
record(.launched)
|
||||
record(.startupCompleted)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -236,8 +236,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 +282,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
|
||||
|
||||
@ -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
|
||||
|
||||
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,9 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
let content = message.content
|
||||
@ -183,7 +188,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 +341,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 +362,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 +388,9 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
return result
|
||||
}
|
||||
@ -427,6 +437,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 {
|
||||
|
||||
@ -501,6 +501,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
|
||||
@ -1564,8 +1566,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()
|
||||
}
|
||||
@ -1700,8 +1702,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
|
||||
)
|
||||
@ -1746,18 +1748,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
|
||||
|
||||
@ -68,11 +68,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 +86,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)
|
||||
|
||||
@ -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 = ""
|
||||
@ -182,7 +185,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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -205,7 +214,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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -287,6 +300,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: $showSidebar,
|
||||
@ -304,6 +328,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) {
|
||||
|
||||
@ -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?
|
||||
@ -57,11 +57,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 +75,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 +132,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
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
53
bitchatTests/NicknameNormalizationTests.swift
Normal file
53
bitchatTests/NicknameNormalizationTests.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// NicknameNormalizationTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Nicknames must compare equal regardless of how the user's keyboard
|
||||
// produced them: "café" as precomposed U+00E9 and as "e" + combining
|
||||
// U+0301 are canonically equivalent but bytewise different, which broke
|
||||
// mention matching, DM resolution, and autocomplete (#214). Storage and
|
||||
// comparison both canonicalize to NFC via String.normalizedNickname.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct NicknameNormalizationTests {
|
||||
/// "café" with a combining acute accent (NFD form)
|
||||
private let decomposed = "cafe\u{0301}"
|
||||
/// "café" with precomposed é (NFC form)
|
||||
private let precomposed = "caf\u{00E9}"
|
||||
|
||||
@Test
|
||||
func canonicallyEquivalentFormsNormalizeIdentically() {
|
||||
// Sanity: the raw forms really are different strings byte-wise …
|
||||
#expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count)
|
||||
// … and normalization unifies them.
|
||||
#expect(decomposed.normalizedNickname == precomposed.normalizedNickname)
|
||||
#expect(decomposed.normalizedNickname == precomposed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func asciiNicknamesPassThroughUnchanged() {
|
||||
#expect("alice_42".normalizedNickname == "alice_42")
|
||||
#expect("".normalizedNickname == "")
|
||||
}
|
||||
|
||||
@Test
|
||||
func validateNicknameReturnsCanonicalForm() {
|
||||
#expect(InputValidator.validateNickname(decomposed) == precomposed)
|
||||
#expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed)
|
||||
// Validation behavior is otherwise unchanged.
|
||||
#expect(InputValidator.validateNickname(" ") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func collisionSuffixSplittingSurvivesNormalization() {
|
||||
let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix()
|
||||
#expect(base == precomposed)
|
||||
#expect(suffix == "#ab12")
|
||||
}
|
||||
}
|
||||
@ -723,9 +723,9 @@ struct NoiseCoverageTests {
|
||||
// A failed startup requirement must not strand a late thread in
|
||||
// the blocking test double after the test has returned.
|
||||
oldSession.resumeDecrypt()
|
||||
_ = decryptResult.wait(timeout: 5)
|
||||
_ = decryptResult.wait(timeout: TestConstants.settleTimeout)
|
||||
if let promotionResultForCleanup {
|
||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||
_ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@ -751,15 +751,19 @@ struct NoiseCoverageTests {
|
||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||
promotionThread.qualityOfService = .userInitiated
|
||||
promotionThread.start()
|
||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||
try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success)
|
||||
#expect(
|
||||
// test-timing-ok: a NEGATIVE wait — it asserts the promotion has
|
||||
// NOT completed yet, so a long deadline would only make the suite
|
||||
// slow while still passing. A starved runner can only make this
|
||||
// more likely to hold, never less.
|
||||
promotionResult.wait(timeout: 0.05) == nil,
|
||||
"Promotion must wait for the exact decrypting-session lease"
|
||||
)
|
||||
|
||||
oldSession.resumeDecrypt()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
|
||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||
|
||||
@ -580,8 +580,16 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
/// constrained CI runners (2-core, serialized testing) can starve the
|
||||
/// detached utility-priority fetch task for seconds before it runs, and
|
||||
/// a successful wait returns as soon as the condition becomes true.
|
||||
/// Default deliberately far larger than the work being awaited.
|
||||
///
|
||||
/// The directory performs its fetch in a `Task.detached(priority: .utility)`,
|
||||
/// and utility priority competes with every other suite on a CI runner. At
|
||||
/// ten seconds the retry-scheduling test timed out at exactly 10.06s with
|
||||
/// the retry never scheduled — which reads like a missing retry rather than
|
||||
/// a starved background task. Returning as soon as the condition holds means
|
||||
/// a longer deadline only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 10.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () async -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@ -188,7 +188,7 @@ struct PTTBurstPlayerTests {
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@ -85,24 +85,16 @@ struct ProtocolContractTests {
|
||||
func transportDefaults_forwardOrNoOp() {
|
||||
let probe = DefaultTransportProbe()
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: "voice.m4a",
|
||||
fileSize: 4,
|
||||
mimeType: "audio/mp4",
|
||||
content: Data([1, 2, 3, 4])
|
||||
)
|
||||
|
||||
probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date())
|
||||
probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01]))
|
||||
probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02]))
|
||||
probe.sendFileBroadcast(filePacket, transferId: "tx-1")
|
||||
probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2")
|
||||
probe.cancelTransfer("tx-3")
|
||||
probe.declinePendingFile(id: "pending")
|
||||
|
||||
#expect(probe.sentMessages.count == 1)
|
||||
#expect(probe.sentMessages.first?.content == "hello")
|
||||
#expect(probe.acceptPendingFile(id: "pending") == nil)
|
||||
// Mesh-only features are capability protocols now, not inert
|
||||
// defaults: a core-only transport simply doesn't have them.
|
||||
#expect(!(probe as AnyObject is MeshFileTransferring))
|
||||
#expect(!(probe as AnyObject is MeshDiagnosing))
|
||||
#expect(probe.peerCapabilities(peerID).isEmpty)
|
||||
// Secure delivery defaults to prompt delivery (itself defaulting to
|
||||
// reachability) for transports without a forgeable link layer.
|
||||
#expect(probe.canDeliverSecurely(to: peerID) == false)
|
||||
|
||||
@ -75,6 +75,70 @@ final class BitchatFilePacketTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
/// The TLV tag list is a floor, not a ceiling: a decoder that bails on the
|
||||
/// first tag it does not know makes the format unextendable, because a field
|
||||
/// the sender considered optional costs the receiver the whole file. This
|
||||
/// decoder skips them (`case nil: continue`) and that has to stay true — it
|
||||
/// is load-bearing for any peer, version or third-party client that adds a
|
||||
/// field we have not seen. `PrivateMediaMessageIdentity` exists precisely
|
||||
/// because the Android decoder does *not* do this, so the asymmetry is real
|
||||
/// and worth pinning on the side that gets it right.
|
||||
func testDecodeSkipsUnknownTLVTypesInsteadOfDroppingTheFile() throws {
|
||||
let content = Data((0..<64).map { UInt8($0) })
|
||||
let unknownValue = Data("some-message-id".utf8)
|
||||
var data = Data()
|
||||
|
||||
// fileName
|
||||
data.append(0x01)
|
||||
data.append(contentsOf: [0x00, 0x09])
|
||||
data.append(Data("photo.jpg".utf8))
|
||||
// fileSize
|
||||
data.append(0x02)
|
||||
data.append(contentsOf: [0x00, 0x04])
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
|
||||
// mimeType
|
||||
data.append(0x03)
|
||||
data.append(contentsOf: [0x00, 0x0A])
|
||||
data.append(Data("image/jpeg".utf8))
|
||||
// An unknown tag, where an encoder appending content last would put it
|
||||
data.append(0x05)
|
||||
data.append(contentsOf: [0x00, UInt8(unknownValue.count)])
|
||||
data.append(unknownValue)
|
||||
// content
|
||||
data.append(0x04)
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
|
||||
data.append(content)
|
||||
|
||||
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
|
||||
XCTAssertEqual(decoded.fileName, "photo.jpg")
|
||||
XCTAssertEqual(decoded.mimeType, "image/jpeg")
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
/// Same contract for an extension that trails the content, which a decoder
|
||||
/// stopping at the first unknown tag would also lose.
|
||||
func testDecodeSkipsAnUnknownTLVTrailingTheContent() throws {
|
||||
let content = Data(repeating: 0x7F, count: 16)
|
||||
var data = Data()
|
||||
|
||||
data.append(0x01)
|
||||
data.append(contentsOf: [0x00, 0x08])
|
||||
data.append(Data("note.m4a".utf8))
|
||||
data.append(0x04)
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
|
||||
data.append(content)
|
||||
data.append(0x7F)
|
||||
data.append(contentsOf: [0x00, 0x04])
|
||||
data.append(Data([0x11, 0x11, 0x11, 0x11]))
|
||||
|
||||
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
|
||||
XCTAssertEqual(decoded.fileName, "note.m4a")
|
||||
XCTAssertNil(decoded.mimeType)
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
|
||||
let senderKey = Data(repeating: 0x11, count: 32)
|
||||
let recipientKey = Data(repeating: 0x22, count: 32)
|
||||
|
||||
@ -68,6 +68,21 @@ struct BLEAnnounceThrottleTests {
|
||||
#expect(accepted.value == 1)
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func resetForgetsThrottleDebtSoARotationAnnounceIsNeverSwallowed() {
|
||||
let throttle = BLEAnnounceThrottle(
|
||||
normalMinimumInterval: 1,
|
||||
forcedMinimumInterval: 1
|
||||
)
|
||||
let now = Date()
|
||||
#expect(throttle.shouldSend(force: true, now: now))
|
||||
// A panic inside the forced window would be throttled...
|
||||
#expect(!throttle.shouldSend(force: true, now: now.addingTimeInterval(0.2)))
|
||||
// ...so the rotation resets the debt and announces immediately.
|
||||
throttle.reset()
|
||||
#expect(throttle.shouldSend(force: true, now: now.addingTimeInterval(0.3)))
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedCounter: @unchecked Sendable {
|
||||
|
||||
@ -222,7 +222,7 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(message?.isPrivate == false)
|
||||
#expect(message?.senderPeerID == remotePeerID)
|
||||
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
|
||||
#expect(message?.deliveryStatus == nil)
|
||||
#expect(message?.deliveryStatus == .notSentYet)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
75
bitchatTests/Services/BLELinkAuthStateTests.swift
Normal file
75
bitchatTests/Services/BLELinkAuthStateTests.swift
Normal file
@ -0,0 +1,75 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELinkAuthStateTests {
|
||||
private let peerID = PeerID(str: "1122334455667788")
|
||||
private let link = BLEIngressLinkID.peripheral("periph-a")
|
||||
|
||||
@Test
|
||||
func authenticationBindsToTheExactLinkAndOwner() {
|
||||
var auth = BLELinkAuthState()
|
||||
auth.markAuthenticated(link, owner: peerID)
|
||||
|
||||
#expect(auth.isAuthenticated(link, for: peerID))
|
||||
#expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff")))
|
||||
#expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID))
|
||||
|
||||
auth.retireLink(link)
|
||||
#expect(!auth.isAuthenticated(link, for: peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func retireLinksOwnedByPeerReturnsAndRetiresThemAll() {
|
||||
var auth = BLELinkAuthState()
|
||||
auth.markAuthenticated(.peripheral("periph-a"), owner: peerID)
|
||||
auth.markAuthenticated(.central("central-a"), owner: peerID)
|
||||
auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff"))
|
||||
|
||||
let departed = Set(auth.retireLinks(ownedBy: peerID))
|
||||
|
||||
#expect(departed == [.peripheral("periph-a"), .central("central-a")])
|
||||
#expect(auth.links(ownedBy: peerID).isEmpty)
|
||||
#expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff")))
|
||||
}
|
||||
|
||||
@Test
|
||||
func rebindCooldownPermitsOncePerWindowAndAgesOut() {
|
||||
var auth = BLELinkAuthState()
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
|
||||
#expect(first)
|
||||
let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30)
|
||||
#expect(!withinWindow)
|
||||
// A different link has its own allowance.
|
||||
let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30)
|
||||
#expect(otherLink)
|
||||
// The window ages out.
|
||||
let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30)
|
||||
#expect(afterWindow)
|
||||
}
|
||||
|
||||
@Test
|
||||
func containmentCooldownsSurviveASessionReset() {
|
||||
var auth = BLELinkAuthState()
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
auth.markAuthenticated(link, owner: peerID)
|
||||
let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
|
||||
let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30)
|
||||
#expect(rebindBefore)
|
||||
#expect(retirementBefore)
|
||||
|
||||
// Panic/emergency resets wipe proofs and epochs — but a stable
|
||||
// CoreBluetooth UUID must not earn a fresh rebind or retirement
|
||||
// allowance just because the session state around it was wiped.
|
||||
auth.removeAll()
|
||||
|
||||
#expect(!auth.isAuthenticated(link, for: peerID))
|
||||
let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30)
|
||||
let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30)
|
||||
#expect(!rebindAfterReset)
|
||||
#expect(!retirementAfterReset)
|
||||
}
|
||||
}
|
||||
111
bitchatTests/Services/BLELinkBindingsTests.swift
Normal file
111
bitchatTests/Services/BLELinkBindingsTests.swift
Normal file
@ -0,0 +1,111 @@
|
||||
import BitFoundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELinkBindingsTests {
|
||||
private let peerID = PeerID(str: "1122334455667788")
|
||||
private let otherPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
@Test
|
||||
func centralBindingExposesBoundPeerAndLinks() {
|
||||
var bindings = BLELinkBindings()
|
||||
|
||||
bindings.bindCentral("central-a", to: peerID)
|
||||
|
||||
#expect(bindings.peer(forCentralUUID: "central-a") == peerID)
|
||||
#expect(bindings.hasCentral(boundTo: peerID))
|
||||
#expect(bindings.boundPeer(for: .central("central-a")) == peerID)
|
||||
#expect(bindings.links(to: peerID) == [.central("central-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func linksReturnsAllBindingsForPeerAcrossRoles() {
|
||||
var bindings = BLELinkBindings()
|
||||
|
||||
bindings.bindCentral("central-a", to: peerID)
|
||||
bindings.bindCentral("central-b", to: peerID)
|
||||
bindings.bindCentral("central-c", to: otherPeerID)
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
|
||||
#expect(bindings.links(to: peerID) == [.central("central-a"), .central("central-b"), .peripheral("periph-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
|
||||
var bindings = BLELinkBindings()
|
||||
|
||||
bindings.bindCentral("central-a", to: peerID)
|
||||
bindings.bindCentral("central-b", to: otherPeerID)
|
||||
|
||||
let removedPeerIDs = Set(bindings.clearCentrals())
|
||||
|
||||
#expect(removedPeerIDs == Set([peerID, otherPeerID]))
|
||||
#expect(bindings.peer(forCentralUUID: "central-a") == nil)
|
||||
#expect(bindings.links(to: peerID).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rotationRebindDropsTheRetiredIdentitysReverseMapping() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a")
|
||||
|
||||
// The link's owner rotates: the old identity must no longer claim
|
||||
// this link as its preferred peripheral.
|
||||
bindings.bindPeripheral("periph-a", to: otherPeerID)
|
||||
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == nil)
|
||||
#expect(bindings.preferredPeripheralUUID(for: otherPeerID) == "periph-a")
|
||||
#expect(bindings.peer(forPeripheralID: "periph-a") == otherPeerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func removingThePreferredLinkRepairsOntoTheChosenSurvivor() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
bindings.bindPeripheral("periph-b", to: peerID)
|
||||
// periph-b bound last: it is the preferred link.
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b")
|
||||
|
||||
let removed = bindings.peripheralRemoved("periph-b") { remaining in
|
||||
#expect(remaining == ["periph-a"])
|
||||
return remaining.first
|
||||
}
|
||||
|
||||
#expect(removed == peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a")
|
||||
#expect(bindings.links(to: peerID) == [.peripheral("periph-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func removingADuplicateLinkDoesNotStrandThePreferredOne() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
bindings.bindPeripheral("periph-b", to: peerID)
|
||||
|
||||
// Removing the non-preferred duplicate must leave the reverse map
|
||||
// untouched (no repair callback consulted for a non-preferred link).
|
||||
let removed = bindings.peripheralRemoved("periph-a") { _ in
|
||||
Issue.record("survivor choice must not run for a non-preferred link")
|
||||
return nil
|
||||
}
|
||||
|
||||
#expect(removed == peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b")
|
||||
}
|
||||
|
||||
@Test
|
||||
func removingTheLastLinkClearsThePreferredMapping() {
|
||||
var bindings = BLELinkBindings()
|
||||
bindings.bindPeripheral("periph-a", to: peerID)
|
||||
|
||||
let removed = bindings.peripheralRemoved("periph-a") { remaining in
|
||||
#expect(remaining.isEmpty)
|
||||
return nil
|
||||
}
|
||||
|
||||
#expect(removed == peerID)
|
||||
#expect(bindings.preferredPeripheralUUID(for: peerID) == nil)
|
||||
#expect(bindings.links(to: peerID).isEmpty)
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import BitFoundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELinkStateStoreTests {
|
||||
@Test
|
||||
func centralBindingExposesDirectLinkStateAndLinks() {
|
||||
let store = BLELinkStateStore()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
|
||||
store.bindCentral("central-a", to: peerID)
|
||||
|
||||
#expect(store.peerID(forCentralUUID: "central-a") == peerID)
|
||||
#expect(store.directLinkState(for: peerID) == BLEDirectLinkState(hasPeripheral: false, hasCentral: true))
|
||||
#expect(store.links(to: peerID) == [.central("central-a")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func linksReturnsAllCentralBindingsForPeer() {
|
||||
let store = BLELinkStateStore()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let otherPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
store.bindCentral("central-a", to: peerID)
|
||||
store.bindCentral("central-b", to: peerID)
|
||||
store.bindCentral("central-c", to: otherPeerID)
|
||||
|
||||
#expect(store.links(to: peerID) == [.central("central-a"), .central("central-b")])
|
||||
}
|
||||
|
||||
@Test
|
||||
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
|
||||
let store = BLELinkStateStore()
|
||||
let firstPeerID = PeerID(str: "1122334455667788")
|
||||
let secondPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
store.bindCentral("central-a", to: firstPeerID)
|
||||
store.bindCentral("central-b", to: secondPeerID)
|
||||
|
||||
let removedPeerIDs = Set(store.clearCentrals())
|
||||
|
||||
#expect(removedPeerIDs == Set([firstPeerID, secondPeerID]))
|
||||
#expect(store.peerID(forCentralUUID: "central-a") == nil)
|
||||
#expect(store.links(to: firstPeerID).isEmpty)
|
||||
}
|
||||
}
|
||||
84
bitchatTests/Services/BLEMeshPingTrackerTests.swift
Normal file
84
bitchatTests/Services/BLEMeshPingTrackerTests.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEMeshPingTrackerTests {
|
||||
private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe {
|
||||
BLEMeshPingProbe(
|
||||
peerID: peerID,
|
||||
sentAt: Date(timeIntervalSince1970: 1_000),
|
||||
lifecycleGeneration: 1,
|
||||
completion: { _ in },
|
||||
timeout: DispatchWorkItem {}
|
||||
)
|
||||
}
|
||||
|
||||
@Test func resolveReturnsProbeOnlyForTheProbedPeer() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
tracker.register(makeProbe(peerID: probed), nonce: nonce)
|
||||
|
||||
// A pong claiming the right nonce from the wrong peer must not
|
||||
// consume the probe.
|
||||
let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb"))
|
||||
#expect(wrongPeer == nil)
|
||||
let rightPeer = tracker.resolve(nonce: nonce, from: probed)
|
||||
#expect(rightPeer != nil)
|
||||
// Consumed exactly once.
|
||||
let secondResolve = tracker.resolve(nonce: nonce, from: probed)
|
||||
#expect(secondResolve == nil)
|
||||
}
|
||||
|
||||
@Test func expireConsumesTheProbeSoResolveCannotFireTwice() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9])
|
||||
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
tracker.register(makeProbe(peerID: probed), nonce: nonce)
|
||||
|
||||
let firstExpire = tracker.expire(nonce: nonce)
|
||||
#expect(firstExpire != nil)
|
||||
let secondExpire = tracker.expire(nonce: nonce)
|
||||
#expect(secondExpire == nil)
|
||||
let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed)
|
||||
#expect(resolveAfterExpire == nil)
|
||||
}
|
||||
|
||||
@Test func inboundBudgetIsPerLinkAndBounded() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let now = Date(timeIntervalSince1970: 2_000)
|
||||
let linkA = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
let linkB = PeerID(str: "bbbbbbbbbbbbbbbb")
|
||||
|
||||
var allowedOnA = 0
|
||||
for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) {
|
||||
if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 }
|
||||
}
|
||||
#expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink)
|
||||
// One saturated link must not consume another link's budget.
|
||||
let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now)
|
||||
#expect(allowedOnB)
|
||||
}
|
||||
|
||||
@Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let now = Date(timeIntervalSince1970: 3_000)
|
||||
let link = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4])
|
||||
tracker.register(makeProbe(peerID: link), nonce: nonce)
|
||||
for _ in 0..<TransportConfig.meshPingInboundMaxPerLink {
|
||||
_ = tracker.shouldRespond(toLink: link, now: now)
|
||||
}
|
||||
let saturated = tracker.shouldRespond(toLink: link, now: now)
|
||||
#expect(!saturated)
|
||||
|
||||
let timeouts = tracker.reset()
|
||||
|
||||
#expect(timeouts.count == 1)
|
||||
let resolveAfterReset = tracker.resolve(nonce: nonce, from: link)
|
||||
#expect(resolveAfterReset == nil)
|
||||
let allowedAfterReset = tracker.shouldRespond(toLink: link, now: now)
|
||||
#expect(allowedAfterReset)
|
||||
}
|
||||
}
|
||||
@ -260,6 +260,48 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func blockedDuplicateAtFrontOfQueueDoesNotStarveALaterUnrelatedPendingTransfer() {
|
||||
// Bug: reservePendingStarts spent the slot budget on a pending
|
||||
// request the moment it was dequeued, before checking whether that
|
||||
// request would actually be admitted. A resend of still-active
|
||||
// content sitting at the front of the queue therefore consumed a
|
||||
// slot even though it was deferred back to the queue rather than
|
||||
// started -- starving an unrelated, genuinely startable transfer
|
||||
// right behind it until some other transfer happened to complete.
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
let t1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a")
|
||||
let t2 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t2", payload: "file-b")
|
||||
let dupT1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a")
|
||||
let unrelated = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t3", payload: "file-c")
|
||||
|
||||
_ = scheduler.submit(t1, maxConcurrentTransfers: 2)
|
||||
_ = scheduler.submit(t2, maxConcurrentTransfers: 2)
|
||||
#expect(scheduler.activeCount == 2)
|
||||
|
||||
// Both slots are full, so a resend of "t1" (still active) and an
|
||||
// unrelated transfer both land in the pending queue, in that order.
|
||||
_ = scheduler.submit(dupT1, maxConcurrentTransfers: 2)
|
||||
_ = scheduler.submit(unrelated, maxConcurrentTransfers: 2)
|
||||
#expect(scheduler.pendingCount == 2)
|
||||
|
||||
// "t2" finishes; "t1" stays active, so the queued "t1" resend at the
|
||||
// front of the queue is still blocked when we reserve pending starts.
|
||||
let didActivate = scheduler.activateReservedTransfer(id: "t2", totalFragments: 1, workItems: [])
|
||||
#expect(didActivate)
|
||||
#expect(scheduler.markFragmentSent(transferId: "t2") == .complete(sentFragments: 1, totalFragments: 1))
|
||||
|
||||
let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 2)
|
||||
|
||||
let startedTransferIds: [String] = starts.compactMap {
|
||||
if case let .start(_, reservedTransferId) = $0 { return reservedTransferId }
|
||||
return nil
|
||||
}
|
||||
#expect(startedTransferIds == ["t3"], "the unrelated pending transfer must start in the same pass despite the blocked front item")
|
||||
#expect(scheduler.activeCount == 2, "t1 (still running) and the newly-started t3")
|
||||
#expect(scheduler.pendingCount == 1, "only the blocked t1 resend remains queued")
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
|
||||
192
bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift
Normal file
192
bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift
Normal file
@ -0,0 +1,192 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPrivateMediaSessionStoreTests {
|
||||
private let peer = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
private let fingerprint = "ABCDEF0123456789"
|
||||
|
||||
@Test func sameGenerationReconciliationDoesNotRearmProofMachinery() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
let generation = UUID()
|
||||
let fresh = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
#expect(fresh != nil)
|
||||
// A quarantine-restore of the same generation is a reconciliation,
|
||||
// not a new session: no new watchdog, no waiter churn.
|
||||
let again = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
#expect(again == nil)
|
||||
// The original watchdog identity survives.
|
||||
#expect(store.proofTimeoutTarget(for: peer)?.nonce == fresh?.watchdogNonce)
|
||||
}
|
||||
|
||||
@Test func freshGenerationRejectsFingerprintMismatchedWaiters() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
var completed = 0
|
||||
_ = store.registerPolicyResolution(
|
||||
for: peer, fingerprint: fingerprint, requestID: UUID(),
|
||||
completion: { _ in completed += 1 }
|
||||
)
|
||||
// The replacement session authenticates a DIFFERENT identity: the
|
||||
// old waiters must come back for rejection instead of riding along.
|
||||
let fresh = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: "FEDCBA9876543210", generation: UUID()
|
||||
)
|
||||
#expect(fresh?.rejected.count == 1)
|
||||
#expect(!store.hasPendingPolicyResolution(for: peer))
|
||||
}
|
||||
|
||||
@Test func applyPeerStateRequiresTheCurrentGenerationAndReleasesWaiters() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
let generation = UUID()
|
||||
_ = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
_ = store.registerPolicyResolution(
|
||||
for: peer, fingerprint: fingerprint, requestID: UUID(),
|
||||
completion: { _ in }
|
||||
)
|
||||
|
||||
// A proof bound to a superseded generation must not classify the
|
||||
// replacement session.
|
||||
let stale = store.applyAuthenticatedPeerState(
|
||||
for: peer, fingerprint: fingerprint, generation: UUID(),
|
||||
capabilities: [.privateMedia]
|
||||
)
|
||||
#expect(stale == nil)
|
||||
|
||||
let released = store.applyAuthenticatedPeerState(
|
||||
for: peer, fingerprint: fingerprint, generation: generation,
|
||||
capabilities: [.privateMedia]
|
||||
)
|
||||
#expect(released?.count == 1)
|
||||
// Proof landed: the watchdog is retired, so nothing can time out.
|
||||
#expect(store.proofTimeoutTarget(for: peer) == nil)
|
||||
}
|
||||
|
||||
@Test func expiryRequiresTheLiveDeadlineIdentityAndPinsTheMarker() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
let generation = UUID()
|
||||
let fresh = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
let nonce = fresh?.watchdogNonce ?? UUID()
|
||||
|
||||
// A stale nonce (superseded deadline) must not expire anything.
|
||||
let stale = store.expireProofDeadline(
|
||||
for: peer, fingerprint: fingerprint,
|
||||
sessionGeneration: generation, nonce: UUID()
|
||||
)
|
||||
#expect(!stale.expired)
|
||||
|
||||
let expired = store.expireProofDeadline(
|
||||
for: peer, fingerprint: fingerprint,
|
||||
sessionGeneration: generation, nonce: nonce
|
||||
)
|
||||
#expect(expired.expired)
|
||||
#expect(!expired.deferredOutbound)
|
||||
// The timeout marker now classifies this generation as unproven.
|
||||
#expect(store.policyInputs(for: peer).timedOut != nil)
|
||||
}
|
||||
|
||||
@Test func expiryReportsTheConvergenceDeferralSoDrainsStayParked() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
let generation = UUID()
|
||||
let fresh = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
store.setOutboundDeferredUntilConvergence(peer)
|
||||
|
||||
let expired = store.expireProofDeadline(
|
||||
for: peer, fingerprint: fingerprint,
|
||||
sessionGeneration: generation, nonce: fresh?.watchdogNonce ?? UUID()
|
||||
)
|
||||
#expect(expired.expired)
|
||||
#expect(expired.deferredOutbound)
|
||||
|
||||
store.clearOutboundDeferredUntilConvergence(peer)
|
||||
// Deferral is per-peer state, not per-deadline: once convergence
|
||||
// clears it, a later expiry may drain.
|
||||
_ = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: UUID()
|
||||
)
|
||||
let next = store.proofTimeoutTarget(for: peer)
|
||||
let afterClear = store.expireProofDeadline(
|
||||
for: peer, fingerprint: fingerprint,
|
||||
sessionGeneration: next?.generation ?? nil, nonce: next?.nonce ?? UUID()
|
||||
)
|
||||
#expect(afterClear.expired)
|
||||
#expect(!afterClear.deferredOutbound)
|
||||
}
|
||||
|
||||
@Test func policyWaitersReuseTheLiveWatchdogDeadline() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
let generation = UUID()
|
||||
let fresh = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
|
||||
// First waiter piggybacks on the watchdog's deadline (case-insensitive
|
||||
// fingerprint match): no second timeout gets scheduled.
|
||||
let first = store.registerPolicyResolution(
|
||||
for: peer, fingerprint: fingerprint.lowercased(), requestID: UUID(),
|
||||
completion: { _ in }
|
||||
)
|
||||
#expect(first.registered)
|
||||
#expect(!first.shouldSchedule)
|
||||
#expect(first.nonce == fresh?.watchdogNonce)
|
||||
|
||||
// Later waiters join the existing set.
|
||||
let second = store.registerPolicyResolution(
|
||||
for: peer, fingerprint: fingerprint, requestID: UUID(),
|
||||
completion: { _ in }
|
||||
)
|
||||
#expect(second.registered)
|
||||
#expect(!second.shouldSchedule)
|
||||
}
|
||||
|
||||
@Test func clearSessionRebasesWaitersOntoANilGenerationDeadline() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
let generation = UUID()
|
||||
_ = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: generation
|
||||
)
|
||||
_ = store.registerPolicyResolution(
|
||||
for: peer, fingerprint: fingerprint, requestID: UUID(),
|
||||
completion: { _ in }
|
||||
)
|
||||
store.setOutboundDeferredUntilConvergence(peer)
|
||||
|
||||
let rearm = store.clearSession(for: peer)
|
||||
|
||||
// Waiters survive the clear but their deadline is rebased so the
|
||||
// old generation's timeout can no longer claim them.
|
||||
#expect(rearm != nil)
|
||||
#expect(store.currentGeneration(for: peer) == nil)
|
||||
#expect(store.hasPendingPolicyResolution(for: peer))
|
||||
let target = store.proofTimeoutTarget(for: peer)
|
||||
#expect(target?.generation == nil)
|
||||
#expect(target?.nonce == rearm?.nonce)
|
||||
}
|
||||
|
||||
@Test func peerStateSendsAreOncePerGenerationPerKind() {
|
||||
let store = BLEPrivateMediaSessionStore()
|
||||
_ = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: UUID()
|
||||
)
|
||||
#expect(store.markPeerStateSend(for: peer, echo: false))
|
||||
#expect(!store.markPeerStateSend(for: peer, echo: false))
|
||||
#expect(store.markPeerStateSend(for: peer, echo: true))
|
||||
#expect(!store.markPeerStateSend(for: peer, echo: true))
|
||||
|
||||
// A fresh generation resets both slots.
|
||||
_ = store.beginAuthenticatedGeneration(
|
||||
for: peer, fingerprint: fingerprint, generation: UUID()
|
||||
)
|
||||
#expect(store.markPeerStateSend(for: peer, echo: false))
|
||||
}
|
||||
}
|
||||
85
bitchatTests/Services/BLEQueueContractTests.swift
Normal file
85
bitchatTests/Services/BLEQueueContractTests.swift
Normal file
@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Pins the BLE transport's sync-edge order so it stays structural instead
|
||||
/// of decaying back into per-site discipline:
|
||||
///
|
||||
/// main / test threads ──sync──▶ engine ──sync──▶ bleQueue
|
||||
/// └──sync──▶ noise / identity queues
|
||||
///
|
||||
/// and never the reverse. `onEngine` is the single place allowed to
|
||||
/// sync-enter the engine — it carries the debug trap that catches a
|
||||
/// bleQueue caller before it can pair with `readLinkState` into an ABBA
|
||||
/// deadlock (the class of the July 9 field freeze). A raw
|
||||
/// `messageQueue.sync` bypasses that trap, and a `DispatchQueue.main.sync`
|
||||
/// from transport code completes a cycle with the main actor's sync reads.
|
||||
///
|
||||
/// Waive a line with `queue-contract-ok:` plus a reason.
|
||||
struct BLEQueueContractTests {
|
||||
static let waiver = "queue-contract-ok:"
|
||||
|
||||
private static let bleRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // Services
|
||||
.deletingLastPathComponent() // bitchatTests
|
||||
.deletingLastPathComponent() // repo root
|
||||
.appendingPathComponent("bitchat/Services/BLE")
|
||||
|
||||
private struct Line {
|
||||
let file: String
|
||||
let number: Int
|
||||
let text: String
|
||||
let waived: Bool
|
||||
}
|
||||
|
||||
private static func bleLines() throws -> [Line] {
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: bleRoot,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
var out: [Line] = []
|
||||
while let url = enumerator?.nextObject() as? URL {
|
||||
guard url.pathExtension == "swift" else { continue }
|
||||
let name = url.lastPathComponent
|
||||
let texts = try String(contentsOf: url, encoding: .utf8)
|
||||
.components(separatedBy: .newlines)
|
||||
for (index, text) in texts.enumerated() {
|
||||
var waived = text.contains(waiver)
|
||||
var back = index - 1
|
||||
while !waived, back >= 0 {
|
||||
let previous = texts[back].trimmingCharacters(in: .whitespaces)
|
||||
guard previous.hasPrefix("//") else { break }
|
||||
waived = previous.contains(waiver)
|
||||
back -= 1
|
||||
}
|
||||
out.append(Line(file: name, number: index + 1, text: text, waived: waived))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func offenders(matching pattern: String) throws -> [String] {
|
||||
try Self.bleLines()
|
||||
.filter { !$0.waived && $0.text.contains(pattern) }
|
||||
.map { "\($0.file):\($0.number): \($0.text.trimmingCharacters(in: .whitespaces))" }
|
||||
}
|
||||
|
||||
@Test func onlyOnEngineSyncEntersTheEngine() throws {
|
||||
let hits = try offenders(matching: "messageQueue.sync")
|
||||
#expect(hits.isEmpty, "Raw messageQueue.sync bypasses onEngine's bleQueue trap; route through onEngine (or waive with a reason): \(hits)")
|
||||
}
|
||||
|
||||
@Test func transportCodeNeverSyncDispatchesToMain() throws {
|
||||
let hits = try offenders(matching: "DispatchQueue.main.sync")
|
||||
#expect(hits.isEmpty, "A main.sync from transport code can complete an ABBA cycle with the main actor's sync reads: \(hits)")
|
||||
}
|
||||
|
||||
@Test func theCollectionsQueueStaysDeleted() throws {
|
||||
let hits = try offenders(matching: "collectionsQueue")
|
||||
#expect(hits.isEmpty, "Engine state has exactly one serial domain; do not reintroduce a side queue: \(hits)")
|
||||
}
|
||||
|
||||
@Test func deferredEngineWorkGoesThroughTheScheduler() throws {
|
||||
let hits = try offenders(matching: "messageQueue.asyncAfter")
|
||||
#expect(hits.isEmpty, "Engine delays must use BLEEngineScheduling so tests can drive protocol deadlines with a manual clock: \(hits)")
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,8 @@ struct BLERedundantLinkPolicyTests {
|
||||
private let peer = PeerID(str: "1122334455667788")
|
||||
private let otherPeer = PeerID(str: "8877665544332211")
|
||||
|
||||
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true) -> BLERedundantLinkPolicy.PeripheralLink {
|
||||
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable)
|
||||
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true, connectedAt: Date? = nil) -> BLERedundantLinkPolicy.PeripheralLink {
|
||||
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable, lastConnectedAt: connectedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -131,4 +131,144 @@ struct BLERedundantLinkPolicyTests {
|
||||
)
|
||||
#expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"]))
|
||||
}
|
||||
|
||||
// MARK: Connect-recency preference (the July 31 retire↔reconnect fix)
|
||||
|
||||
@Test
|
||||
func newestConnectionWinsOverIngressAndBindingAnchors() {
|
||||
// Field oscillation: the restored old-address link (no connect
|
||||
// timestamp) carried the announce ingress AND the binding, so it
|
||||
// kept winning — and the cancelled fresh-address link kept getting
|
||||
// rediscovered and reconnected. Physical connect recency must beat
|
||||
// both announce anchors.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-restored",
|
||||
mostRecentlyBoundUUID: "p-restored",
|
||||
links: [
|
||||
link("p-restored", peer),
|
||||
link("p-fresh", peer, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-fresh")
|
||||
}
|
||||
|
||||
@Test
|
||||
func amongTimestampedLinksTheNewestWins() {
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-older",
|
||||
mostRecentlyBoundUUID: "p-older",
|
||||
links: [
|
||||
link("p-older", peer, connectedAt: now.addingTimeInterval(-30)),
|
||||
link("p-newer", peer, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-newer")
|
||||
}
|
||||
|
||||
@Test
|
||||
func newestLinkMidDiscoveryDefersInsteadOfKeepingOlderWritable() {
|
||||
// The fresh connection hasn't finished service discovery, so it is
|
||||
// not writable yet. Keeping the older writable (restored) link now
|
||||
// would cancel the one connection on the currently advertised
|
||||
// address and recreate the oscillation — defer to a later announce.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-writable",
|
||||
mostRecentlyBoundUUID: "p-writable",
|
||||
links: [
|
||||
link("p-writable", peer, connectedAt: now.addingTimeInterval(-30)),
|
||||
link("p-fresh-bare", peer, writable: false, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func restoredWritableAnchorAlsoDefersToFreshUnwritableLink() {
|
||||
// Same discovery window as above, but the writable duplicate is a
|
||||
// restored link with no connect timestamp at all — the exact field
|
||||
// topology. It must not win just because the fresh link is bare.
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-restored",
|
||||
mostRecentlyBoundUUID: "p-restored",
|
||||
links: [
|
||||
link("p-restored", peer),
|
||||
link("p-fresh-bare", peer, writable: false, connectedAt: Date())
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func coNewestWritableLinkStillWinsOverBareTwin() {
|
||||
// Two links share the newest timestamp and one is writable: no
|
||||
// discovery window to wait out — the writable co-newest survives.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: nil,
|
||||
mostRecentlyBoundUUID: nil,
|
||||
links: [
|
||||
link("p-bare", peer, writable: false, connectedAt: now),
|
||||
link("p-writable", peer, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-writable")
|
||||
}
|
||||
|
||||
@Test
|
||||
func allUnwritableDuplicatesConsolidateByConnectRecency() {
|
||||
// No writable link exists at all: nothing can be stranded, so the
|
||||
// newest connection consolidates immediately.
|
||||
let now = Date()
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-older",
|
||||
mostRecentlyBoundUUID: "p-older",
|
||||
links: [
|
||||
link("p-older", peer, writable: false, connectedAt: now.addingTimeInterval(-30)),
|
||||
link("p-newer", peer, writable: false, connectedAt: now)
|
||||
],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-newer")
|
||||
}
|
||||
|
||||
@Test
|
||||
func allRestoredLinksFallBackToAnnounceAnchors() {
|
||||
// No connect timestamps at all (every link restored): the legacy
|
||||
// ingress-then-binding preference still decides.
|
||||
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-ingress",
|
||||
mostRecentlyBoundUUID: "p-bound",
|
||||
links: [link("p-ingress", peer), link("p-bound", peer)],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(kept == "p-ingress")
|
||||
}
|
||||
|
||||
@Test
|
||||
func timestampTiesBreakByAnchorsThenDeterministically() {
|
||||
let now = Date()
|
||||
let anchored = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: "p-b",
|
||||
mostRecentlyBoundUUID: nil,
|
||||
links: [link("p-a", peer, connectedAt: now), link("p-b", peer, connectedAt: now)],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(anchored == "p-b")
|
||||
|
||||
let unanchored = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||||
ingressPeripheralUUID: nil,
|
||||
mostRecentlyBoundUUID: nil,
|
||||
links: [link("p-b", peer, connectedAt: now), link("p-a", peer, connectedAt: now)],
|
||||
peerID: peer
|
||||
)
|
||||
#expect(unanchored == "p-a")
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
wait(for: [expectation], timeout: TestConstants.settleTimeout)
|
||||
XCTAssertTrue(service.isFavorite(peerKey))
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
||||
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
||||
|
||||
@ -227,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@ -114,4 +114,83 @@ struct MediaRetentionTests {
|
||||
func defaultRetentionIsSevenDays() {
|
||||
#expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Media was the one persistence layer that never stated a protection
|
||||
/// class at its write site, so payloads inherited the container
|
||||
/// default. Saves must survive the added write option,
|
||||
/// and on device the class must read back. The simulator's filesystem
|
||||
/// does not model data protection (the attribute reads back nil there),
|
||||
/// so the readback assertion is device-only.
|
||||
@Test
|
||||
func savedMediaSurvivesExplicitProtectionClass() throws {
|
||||
let root = makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
|
||||
let payload = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let saved = try #require(store.save(
|
||||
data: payload,
|
||||
preferredName: "note.m4a",
|
||||
subdirectory: "voicenotes/incoming",
|
||||
fallbackExtension: "m4a",
|
||||
defaultPrefix: "voice"
|
||||
))
|
||||
|
||||
#expect(try Data(contentsOf: saved) == payload)
|
||||
#if !targetEnvironment(simulator)
|
||||
let protection = try FileManager.default.attributesOfItem(
|
||||
atPath: saved.path
|
||||
)[.protectionKey] as? FileProtectionType
|
||||
#expect(protection == .completeUntilFirstUserAuthentication)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Files written before payloads carried an explicit class are stamped
|
||||
/// by the launch-time migration that follows the retention sweep: the
|
||||
/// directory plus each resident file, without error. In-flight live
|
||||
/// captures are left alone, exactly as the sweep leaves them: the
|
||||
/// coordinator may still be writing to one through an open FileHandle,
|
||||
/// and new captures receive the class at creation. Readback is device-only for the same
|
||||
/// reason as above.
|
||||
@Test
|
||||
func migrationStampsPreexistingMediaAndSkipsLiveCaptures() throws {
|
||||
let root = makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
|
||||
|
||||
let legacy = try write(
|
||||
"received.m4a",
|
||||
in: incoming,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
_ = try write(
|
||||
"\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac",
|
||||
in: incoming,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
|
||||
// Exactly the directory itself plus the legacy file; strict equality
|
||||
// is what proves the live capture was not stamped.
|
||||
#expect(store.migrateFileProtectionIfNeeded() == 2)
|
||||
#expect(FileManager.default.fileExists(atPath: legacy.path))
|
||||
#if !targetEnvironment(simulator)
|
||||
let protection = try FileManager.default.attributesOfItem(
|
||||
atPath: legacy.path
|
||||
)[.protectionKey] as? FileProtectionType
|
||||
#expect(protection == .completeUntilFirstUserAuthentication)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A store with no media on disk has nothing to stamp.
|
||||
@Test
|
||||
func migrationWithNoMediaIsANoOp() {
|
||||
let root = makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
|
||||
#expect(store.migrateFileProtectionIfNeeded() == 0)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
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