Compare commits

..
Author SHA1 Message Date
jack f0c8b4e2ea Harden panic recovery and service shutdown 2026-07-25 17:43:12 +02:00
jackandjack 5ba6f32f61 Scope install markers to iOS 2026-07-25 17:15:24 +02:00
jackandjack 43c7fc52ba Make panic wipe deterministic and device-bound 2026-07-25 17:15:24 +02:00
ca18843bb0 Add Persian (fa) localization and an in-app language picker (#1443)
* Add Persian (fa) localization and an in-app language picker

Persian was the one notable gap in the 29-language catalog. Translate all
381 strings (plus the share extension) with proper plural substitutions,
and register fa in knownRegions.

Settings gains a LANGUAGE section: a picker over every language the bundle
ships (native names via Locale), backed by an AppleLanguages override so
users can run bitchat in a language different from the device's. Localization
resolves at process start, so the picker surfaces a restart note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove unused AppLanguageSettings.currentOverride (Periphery)

AppInfoView reads the override via @AppStorage, so the accessor was dead
code and failed the Periphery CI scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-07-25 15:42:17 +02:00
593fd7d737 Harden public intake bounds against untrusted growth (#1451)
* Bound public rate-limit buckets against attacker-keyed growth.

Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop Cashu-looking text from skipping long-message guards.

Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap teleported geohash participant markers.

Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound untrusted Nostr relay frames and event tags.

Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fail soft when Noise handshake state is unexpectedly missing.

Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap geohash nickname cache from remote Nostr events.

FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Avoid overlapping exclusive access in the rate limiter.

Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix rate-limiter tests for mutating allow under #expect.

Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop unused WebSocket data helper; reset rate limiter on panic wipe.

dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:25:46 +02:00
52 changed files with 5214 additions and 713 deletions
+3 -3
View File
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
1. **Identity and cryptographic keys**
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
## Your Controls
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
- **No account:** The project operates no account record for you to request or export.
+1
View File
@@ -337,6 +337,7 @@
es,
ar,
de,
fa,
fr,
he,
id,
+8
View File
@@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject {
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it.
private var prepareForPanic: (@MainActor () -> Void)?
/// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
@@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject {
showScreenshotPrivacyWarning = true
}
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
prepareForPanic = preparation
}
func panicClearAllData() {
prepareForPanic?()
chatViewModel.panicClearAllData()
}
+19 -1
View File
@@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject {
)
)
if chatViewModel.networkActivationAllowed {
GeoRelayDirectory.shared.prefetchIfNeeded()
}
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard chatViewModel.networkActivationAllowed else { return }
guard !started else {
checkForSharedContent()
return
@@ -151,12 +154,14 @@ final class AppRuntime: ObservableObject {
}
func handleDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
@@ -175,6 +180,7 @@ final class AppRuntime: ObservableObject {
didEnterBackground = true
case .active:
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
@@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject {
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
guard chatViewModel.networkActivationAllowed else { return }
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
@@ -273,6 +280,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
@@ -281,6 +290,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
@@ -289,6 +300,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
@@ -297,6 +310,8 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
@@ -313,6 +328,7 @@ private extension AppRuntime {
}
func checkForSharedContent() {
guard chatViewModel.networkActivationAllowed else { return }
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
let clearSharedContent = {
userDefaults.removeObject(forKey: "sharedContent")
@@ -359,7 +375,9 @@ private extension AppRuntime {
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return }
guard chatViewModel.networkActivationAllowed,
started,
becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
+24 -40
View File
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
@Published private(set) var messages: [BitchatMessage] = []
@Published private(set) var isUnread: Bool = false
/// Incrementally-maintained message-ID logical-index map for O(1)
/// dedup and delivery-status lookup. Logical indexes are physical array
/// indexes plus `indexOffset`; trimming from the head advances the offset
/// instead of rewriting every surviving dictionary entry. This matters
/// after the 1337-message cap is reached, when every steady-state tail
/// append evicts one old row.
///
/// Out-of-order inserts and middle removals still reindex only the
/// affected suffix. Full filtering resets the offset while rebuilding.
/// Incrementally-maintained message-ID index map for O(1) dedup and
/// delivery-status lookup. Kept in sync on every mutation:
/// - tail append: single insert
/// - out-of-order insert: suffix reindex from the insertion point
/// - trim: full rebuild `removeFirst(k)` is already O(n), so the
/// rebuild does not change the asymptotics, and trim only happens once
/// the cap (1337) is reached. Simple and correct beats the
/// offset-tracking alternative here.
private var indexByMessageID: [String: Int] = [:]
private var indexOffset = 0
fileprivate init(id: ConversationID, cap: Int) {
self.id = id
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
}
func message(withID messageID: String) -> BitchatMessage? {
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
guard let index = indexByMessageID[messageID] else { return nil }
return messages[index]
}
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
reindex(from: index)
} else {
messages.append(message)
indexByMessageID[message.id] = indexOffset + messages.count - 1
indexByMessageID[message.id] = messages.count - 1
}
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
/// timeline position (in-place updates like media progress reuse the
/// original timestamp); a new message goes through ordered insertion.
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
if let index = physicalIndex(forMessageID: message.id) {
if let index = indexByMessageID[message.id] {
messages[index] = message
return .updated
}
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
/// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = physicalIndex(forMessageID: messageID) else { return false }
guard let index = indexByMessageID[messageID] else { return false }
let message = messages[index]
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
/// observers still need an @Published emission to re-render.
@discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = physicalIndex(forMessageID: messageID) else { return false }
guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index]
return true
}
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
/// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID)
if index == 0 {
indexOffset += 1
} else {
reindex(from: index)
}
return removed
}
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
for id in removedIDs {
indexByMessageID.removeValue(forKey: id)
}
indexOffset = 0
reindex(from: 0)
return removedIDs
}
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
fileprivate func clearMessages() {
messages.removeAll()
indexByMessageID.removeAll()
indexOffset = 0
}
// MARK: Diagnostics
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
let message = messages[position]
// Count equality + every message resolving to its own position
// proves the index is exactly the inverse map (no stale extras).
if let logicalIndex = indexByMessageID[message.id] {
let expectedIndex = indexOffset + position
if logicalIndex != expectedIndex {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
if let index = indexByMessageID[message.id] {
if index != position {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
}
} else {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
private func reindex(from start: Int) {
for index in start..<messages.count {
indexByMessageID[messages[index].id] = indexOffset + index
indexByMessageID[messages[index].id] = index
}
}
private func physicalIndex(forMessageID messageID: String) -> Int? {
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
let index = logicalIndex - indexOffset
guard messages.indices.contains(index) else { return nil }
return index
}
/// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] }
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
indexByMessageID.removeValue(forKey: id)
}
messages.removeFirst(overflow)
indexOffset += overflow
reindex(from: 0)
return trimmedIDs
}
}
@@ -860,8 +844,8 @@ extension Conversation {
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
func _testCorruptIndexEntries() {
guard messages.count >= 2 else { return }
indexByMessageID[messages[0].id] = indexOffset + 1
indexByMessageID[messages[1].id] = indexOffset
indexByMessageID[messages[0].id] = 1
indexByMessageID[messages[1].id] = 0
}
/// Drops a message's index entry entirely (count mismatch + missing).
@@ -875,8 +859,8 @@ extension Conversation {
func _testCorruptOrderingPreservingIndex() {
guard messages.count >= 2 else { return }
messages.swapAt(0, messages.count - 1)
indexByMessageID[messages[0].id] = indexOffset
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
indexByMessageID[messages[0].id] = 0
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
}
}
@@ -916,7 +900,7 @@ extension ConversationStore {
extension Conversation {
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
messages.append(message)
indexByMessageID[message.id] = indexOffset + messages.count - 1
indexByMessageID[message.id] = messages.count - 1
}
}
#endif
+110 -9
View File
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
let normalized = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
return
}
)
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
}
func clearGeoNicknames() {
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
}
@@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject {
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
/// Stops capture and suppresses every later send before returning.
func panicCancelSynchronously()
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
func cancel() async {
await recorder.cancelRecording(owner: owner)
}
func panicCancelSynchronously() {
recorder.panicCancelSynchronously(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
@@ -216,6 +222,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
}
}
func panicCancelSynchronously() {
// Do not emit a canceled packet: it would itself be pre-panic
// conversation data racing the emergency transport reset.
completed = true
capture.cancel()
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
sendPacket(packet.encode())
@@ -246,6 +246,21 @@ actor VoiceRecorder {
currentURL = nil
}
/// Panic is a synchronous security boundary: the caller must know the
/// microphone, audio-session lease, and partial file are gone before it
/// rotates identities or deletes the media tree. VoiceRecorder is an
/// independent actor and this cleanup path never hops to MainActor, so a
/// short semaphore join is safe even when invoked by the UI actor.
nonisolated
func panicCancelSynchronously(owner: RecordingOwner) {
let finished = DispatchSemaphore(value: 0)
Task {
await cancelRecording(owner: owner)
finished.signal()
}
finished.wait()
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -66,7 +66,10 @@ class NoiseSession {
// Only initiator writes the first message
if role == .initiator {
let message = try handshakeState!.writeMessage()
guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
+19
View File
@@ -701,6 +701,10 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent
}
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
self.created_at = createdAt
@@ -710,6 +714,21 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String
}
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId()
+13 -5
View File
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
guard let data = message.dataWithinInboundLimit,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
}
private extension URLSessionWebSocketTask.Message {
var data: Data? {
/// Prefer rejecting oversized frames before UTF-8/Data materialization
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
switch self {
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
case .string(let text):
guard text.utf8.count <= maxBytes else { return nil }
return text.data(using: .utf8)
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
}
}
}
+230 -5
View File
@@ -2,8 +2,124 @@ import BitLogger
import BitFoundation
import Foundation
struct PanicRecoveryIntent {
let fileMarkerEstablished: Bool
let externalMarkerEstablished: Bool
var hasDurableMarker: Bool {
fileMarkerEstablished || externalMarkerEstablished
}
}
/// Small, dependency-injectable transaction surface used by ChatViewModel.
/// Production persists the same intent in two independent locations before
/// any application state is erased. Tests can inject an ephemeral operation
/// set without touching the developer's Application Support directory.
struct PanicRecoveryOperations {
let isPending: () throws -> Bool
let begin: () -> PanicRecoveryIntent
let wipeMedia: (PanicRecoveryIntent) throws -> Void
let complete: () throws -> Void
static func ephemeral(
wipeMedia: @escaping () throws -> Void = {}
) -> PanicRecoveryOperations {
PanicRecoveryOperations(
isPending: { false },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: false,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in try wipeMedia() },
complete: {}
)
}
static func live(
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
defaults: UserDefaults = .standard
) -> PanicRecoveryOperations {
let defaultsKey = "bitchat.panicResetPending"
return PanicRecoveryOperations(
isPending: {
if defaults.bool(forKey: defaultsKey) {
return true
}
return try fileStore.isPanicRecoveryPending()
},
begin: {
defaults.set(true, forKey: defaultsKey)
let externalMarkerEstablished =
defaults.synchronize()
&& defaults.bool(forKey: defaultsKey)
let fileMarkerEstablished: Bool
do {
try fileStore.markPanicRecoveryPending()
fileMarkerEstablished = true
} catch {
fileMarkerEstablished = false
SecureLogger.error(
"Failed to persist file panic-recovery marker: \(error)",
category: .security
)
}
return PanicRecoveryIntent(
fileMarkerEstablished: fileMarkerEstablished,
externalMarkerEstablished: externalMarkerEstablished
)
},
wipeMedia: { intent in
try fileStore.panicWipe(
hasDurablePendingMarker: intent.hasDurableMarker
)
},
complete: {
// Keep the independent defaults latch until the file marker
// has definitely cleared. Any failure therefore remains
// visible to the next launch.
try fileStore.completePanicRecovery()
defaults.removeObject(forKey: defaultsKey)
guard defaults.synchronize(),
!defaults.bool(forKey: defaultsKey) else {
throw BLEIncomingFileStore.PanicRecoveryError
.externalMarkerCommitFailed
}
}
)
}
}
struct BLEIncomingFileStore {
enum PanicRecoveryError: Error {
case externalMarkerCommitFailed
case markerWriteFailed(Error)
case markerWriteAndMediaWipeFailed(
markerError: Error,
mediaError: Error
)
}
private static let quotaBytes: Int64 = 100 * 1024 * 1024
/// Kept outside `files/` so deleting the media tree cannot erase the
/// fail-closed startup decision before the full panic has committed.
private static let panicRecoveryPendingMarkerFileName =
".panic-recovery-pending"
/// Compatibility with a short-lived development build that used the
/// media-specific name for the same full-transaction latch.
private static let legacyPanicRecoveryPendingMarkerFileName =
".panic-media-wipe-pending"
private static let mediaSubdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
@@ -17,11 +133,96 @@ struct BLEIncomingFileStore {
let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
dateProvider: @escaping () -> Date = Date.init,
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
try $0.write(to: $1, options: .atomic)
}
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
}
/// Panic-wipe every managed incoming and outgoing media artifact before
/// returning. Recreating the directory tree keeps later capture/receive
/// paths usable without allowing a detached cleanup task to race them.
///
/// Marker persistence and deletion are deliberately separate error
/// domains: even when both durable marker channels fail, deletion is
/// still attempted before this method reports the marker failure.
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
let markerError: Error?
do {
try markPanicRecoveryPending()
markerError = nil
} catch {
markerError = error
SecureLogger.error(
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
category: .security
)
}
do {
let filesDirectory = try rootDirectory()
.appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(
subdirectory,
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
)
}
} catch {
if let markerError {
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
markerError: markerError,
mediaError: error
)
}
throw error
}
if let markerError, !hasDurablePendingMarker {
throw PanicRecoveryError.markerWriteFailed(markerError)
}
}
func markPanicRecoveryPending() throws {
let markerURL = try panicRecoveryPendingMarkerURL()
try fileManager.createDirectory(
at: markerURL.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil
)
try panicMarkerWriter(Data([1]), markerURL)
}
func isPanicRecoveryPending() throws -> Bool {
try panicRecoveryMarkerURLs().contains {
fileManager.fileExists(atPath: $0.path)
}
}
func completePanicRecovery() throws {
for markerURL in try panicRecoveryMarkerURLs()
where fileManager.fileExists(atPath: markerURL.path) {
try fileManager.removeItem(at: markerURL)
}
}
/// Resolves (and creates) an incoming-media directory for callers that
@@ -113,15 +314,39 @@ struct BLEIncomingFileStore {
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func rootDirectory() throws -> URL {
try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func panicRecoveryPendingMarkerURL() throws -> URL {
try rootDirectory().appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
)
}
private func panicRecoveryMarkerURLs() throws -> [URL] {
let root = try rootDirectory()
return [
root.appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
),
root.appendingPathComponent(
Self.legacyPanicRecoveryPendingMarkerFileName,
isDirectory: false
)
]
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
+219 -30
View File
@@ -155,6 +155,10 @@ final class BLEService: NSObject {
private var centralManager: CBCentralManager?
private var peripheralManager: CBPeripheralManager?
private var characteristic: CBMutableCharacteristic?
private let shouldInitializeBluetoothManagers: Bool
private let panicLifecycleLock = NSLock()
private var _isPanicSuspended: Bool
private var panicLifecycleGeneration: UInt64 = 0
// MARK: - Identity
@@ -275,10 +279,13 @@ final class BLEService: NSObject {
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true
initializeBluetoothManagers: Bool = true,
startSuspendedForPanicRecovery: Bool = false
) {
self.keychain = keychain
self.idBridge = idBridge
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
self._isPanicSuspended = startSuspendedForPanicRecovery
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init()
@@ -327,37 +334,90 @@ final class BLEService: NSObject {
// any access from another queue (cross-queue reads use readLinkState).
linkStateStore.assumeOwnership(of: bleQueue)
if initializeBluetoothManagers {
// Initialize BLE on background queue to prevent main thread blocking.
if !startSuspendedForPanicRecovery {
initializeBluetoothManagersIfNeeded()
}
// Single maintenance timer for all periodic tasks (dispatch-based for
// determinism). Only run it when real Bluetooth managers exist.
meshBackgroundEnabled = initializeBluetoothManagers
if !startSuspendedForPanicRecovery {
startMaintenanceTimer()
}
// Publish initial empty state
requestPeerDataPublish()
// Initialize gossip sync manager
if !startSuspendedForPanicRecovery {
restartGossipManager()
}
}
private var isPanicSuspended: Bool {
panicLifecycleLock.lock()
defer { panicLifecycleLock.unlock() }
return _isPanicSuspended
}
private func setPanicSuspended(_ suspended: Bool) {
panicLifecycleLock.lock()
if suspended {
panicLifecycleGeneration &+= 1
}
_isPanicSuspended = suspended
panicLifecycleLock.unlock()
}
private func capturePanicLifecycleGeneration() -> UInt64? {
panicLifecycleLock.lock()
defer { panicLifecycleLock.unlock() }
return _isPanicSuspended ? nil : panicLifecycleGeneration
}
private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool {
panicLifecycleLock.lock()
defer { panicLifecycleLock.unlock() }
return !_isPanicSuspended && panicLifecycleGeneration == generation
}
private func initializeBluetoothManagersIfNeeded() {
guard shouldInitializeBluetoothManagers,
centralManager == nil,
peripheralManager == nil,
!isPanicSuspended else { return }
// Initialize BLE on its dedicated delegate queue. On iOS, retain the
// restoration identifiers even when construction was deferred by a
// pending panic-recovery latch.
#if os(iOS)
let centralOptions: [String: Any] = [
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
CBCentralManagerOptionRestoreIdentifierKey:
BLEService.centralRestorationID
]
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
centralManager = CBCentralManager(
delegate: self,
queue: bleQueue,
options: centralOptions
)
let peripheralOptions: [String: Any] = [
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
CBPeripheralManagerOptionRestoreIdentifierKey:
BLEService.peripheralRestorationID
]
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
peripheralManager = CBPeripheralManager(
delegate: self,
queue: bleQueue,
options: peripheralOptions
)
#else
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
#endif
}
// Single maintenance timer for all periodic tasks (dispatch-based for
// determinism). Only run it when real Bluetooth managers exist.
meshBackgroundEnabled = initializeBluetoothManagers
startMaintenanceTimer()
// Publish initial empty state
requestPeerDataPublish()
// Initialize gossip sync manager
restartGossipManager()
}
private func restartGossipManager() {
guard !isPanicSuspended else { return }
// Stop existing
gossipSyncManager?.stop()
@@ -416,7 +476,35 @@ final class BLEService: NSObject {
#endif
}
func resetIdentityForPanic(currentNickname: String) {
/// Close radio admission before application state starts disappearing.
/// CoreBluetooth callbacks consult the same gate and cannot restart scan
/// or advertising while the full panic transaction is incomplete.
func suspendForPanicReset() {
setPanicSuspended(true)
gossipSyncManager?.stop()
gossipSyncManager = nil
// Wait out sends that passed admission before the gate closed. Work
// queued behind this barrier observes `isPanicSuspended` and exits,
// so the radio stop below is a true synchronous boundary.
messageQueue.sync(flags: .barrier) {}
stopServicesImmediatelyForPanic()
clearEmergencySessionState()
}
/// Reopen the radio only after media deletion and recovery-marker commit.
func completePanicReset(restartServices: Bool) {
setPanicSuspended(false)
guard restartServices else { return }
startServices()
sendAnnounce(forceSend: true)
}
func resetIdentityForPanic(
currentNickname: String,
restartServices: Bool = true
) {
gossipSyncManager?.stop()
gossipSyncManager = nil
messageQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.removeAll()
}
@@ -460,16 +548,19 @@ final class BLEService: NSObject {
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
}
restartGossipManager()
setNickname(currentNickname)
// Keep the transport silent until the application-level transaction
// has also removed its media and committed both recovery markers.
myNickname = currentNickname
messageDeduplicator.reset()
messageQueue.async(flags: .barrier) { [weak self] in
self?.selfBroadcastTracker.removeAll()
}
requestPeerDataPublish()
if restartServices {
restartGossipManager()
startServices()
sendAnnounce(forceSend: true)
}
}
// Ensure this runs on message queue to avoid main thread blocking
@@ -481,6 +572,7 @@ final class BLEService: NSObject {
}
return
}
guard !isPanicSuspended else { return }
guard content.count <= maxMessageLength else {
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
@@ -562,7 +654,9 @@ final class BLEService: NSObject {
/// `startServices()` the latter matters after a panic reset, where
/// `stopServices()` cancels and nils the timer.
private func startMaintenanceTimer() {
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
guard !isPanicSuspended,
meshBackgroundEnabled,
maintenanceTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
@@ -575,6 +669,12 @@ final class BLEService: NSObject {
}
func startServices() {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else { return }
initializeBluetoothManagersIfNeeded()
if gossipSyncManager == nil {
restartGossipManager()
}
// Restart the maintenance timer if a prior stopServices() cancelled it
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
// and cache cleanup would never resume until app restart.
@@ -591,7 +691,11 @@ final class BLEService: NSObject {
// Send initial announce after services are ready
// Use longer delay to avoid conflicts with other announces
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
) else { return }
self.sendAnnounce(forceSend: true)
}
}
@@ -660,9 +764,36 @@ final class BLEService: NSObject {
}
}
/// Panic cannot spend its security boundary sending a signed LEAVE or
/// pumping the main run loop. Close the radio and timers immediately;
/// the identity/session cleanup follows synchronously.
private func stopServicesImmediatelyForPanic() {
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll()
}
maintenanceTimer?.cancel()
maintenanceTimer = nil
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
let peripheralsToDisconnect = bleQueue.sync {
linkStateStore.peripheralStates
}
for state in peripheralsToDisconnect {
centralManager?.cancelPeripheralConnection(state.peripheral)
}
}
func emergencyDisconnectAll() {
stopServices()
clearEmergencySessionState()
}
private func clearEmergencySessionState() {
// Clear all sessions and peers
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
@@ -899,6 +1030,7 @@ final class BLEService: NSObject {
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
guard let payload = filePacket.encode() else {
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
return
@@ -935,6 +1067,7 @@ final class BLEService: NSObject {
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
guard let payload = filePacket.encode() else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
return
@@ -1088,6 +1221,7 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
guard !isPanicSuspended else { return }
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
@@ -1169,8 +1303,10 @@ final class BLEService: NSObject {
}
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
guard !isPanicSuspended else { return }
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
let result = self.pendingNotifications.enqueue(
data: data,
targets: centrals,
@@ -1271,6 +1407,7 @@ final class BLEService: NSObject {
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
guard !isPanicSuspended else { return false }
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
@@ -1421,6 +1558,7 @@ final class BLEService: NSObject {
}
private func flushDirectedSpool() {
guard !isPanicSuspended else { return }
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
let toSend = collectionsQueue.sync(flags: .barrier) {
pendingDirectedRelays.drainUnexpired(
@@ -1637,6 +1775,7 @@ final class BLEService: NSObject {
}
}
private func sendAnnounce(forceSend: Bool = false) {
guard !isPanicSuspended else { return }
// Throttle announces to prevent flooding
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
return
@@ -1846,6 +1985,13 @@ 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
@@ -1901,6 +2047,10 @@ extension BLEService: CBCentralManagerDelegate {
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)
@@ -1957,7 +2107,8 @@ extension BLEService: CBCentralManagerDelegate {
}
private func startScanning() {
guard let central = centralManager,
guard !isPanicSuspended,
let central = centralManager,
central.state == .poweredOn,
!central.isScanning else { return }
@@ -1978,6 +2129,7 @@ extension BLEService: CBCentralManagerDelegate {
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard !isPanicSuspended else { return }
let peripheralID = peripheral.identifier.uuidString
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "")
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
@@ -2019,6 +2171,10 @@ extension BLEService: CBCentralManagerDelegate {
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard !isPanicSuspended else {
central.cancelPeripheralConnection(peripheral)
return
}
let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
@@ -2169,7 +2325,9 @@ private extension CBPeripheralState {
extension BLEService {
private func tryConnectFromQueue() {
guard let central = centralManager, central.state == .poweredOn else { return }
guard !isPanicSuspended,
let central = centralManager,
central.state == .poweredOn else { return }
let decision = connectionScheduler.nextCandidate(
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
@@ -2195,6 +2353,7 @@ extension BLEService {
using central: CBCentralManager,
logPrefix: String
) {
guard !isPanicSuspended else { return }
let peripheral = candidate.peripheral
let peripheralID = candidate.peripheralID
linkStateStore.beginConnecting(to: peripheral, at: Date())
@@ -2376,6 +2535,7 @@ extension BLEService {
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
@@ -2402,6 +2562,7 @@ extension BLEService: CBPeripheralDelegate {
}
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
@@ -2449,6 +2610,7 @@ extension BLEService: CBPeripheralDelegate {
}
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
@@ -2566,6 +2728,7 @@ extension BLEService: CBPeripheralDelegate {
}
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)
@@ -2574,6 +2737,7 @@ extension BLEService: CBPeripheralDelegate {
}
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(
@@ -2594,6 +2758,7 @@ extension BLEService: CBPeripheralDelegate {
}
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 {
@@ -2617,6 +2782,12 @@ extension BLEService: CBPeripheralManagerDelegate {
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()
@@ -2677,6 +2848,12 @@ extension BLEService: CBPeripheralManagerDelegate {
#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]) ?? [:]
@@ -2703,6 +2880,10 @@ extension BLEService: CBPeripheralManagerDelegate {
#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
@@ -2718,6 +2899,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
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)
@@ -2759,7 +2941,7 @@ extension BLEService: CBPeripheralManagerDelegate {
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false {
if !isPanicSuspended, peripheral.isAdvertising == false {
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(buildAdvertisementData())
}
@@ -2796,6 +2978,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
guard !isPanicSuspended else { return }
drainPendingNotifications(logPrefix: "✅ Sent")
}
@@ -2856,6 +3039,7 @@ extension BLEService: CBPeripheralManagerDelegate {
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.
@@ -4138,6 +4322,7 @@ extension BLEService {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
// Atomically take all pending items from the queue to avoid race conditions
@@ -4228,7 +4413,10 @@ extension BLEService {
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
) {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
guard let self,
!self.isPanicSuspended,
let central = self.centralManager,
central.state == .poweredOn else { return }
let budget = TransportConfig.bleMaxCentralLinks
- slotReserve
- self.linkStateStore.connectedOrConnectingPeripheralCount
@@ -5535,6 +5723,7 @@ extension BLEService {
// MARK: Consolidated Maintenance
private func performMaintenance() {
guard !isPanicSuspended else { return }
maintenanceCounter += 1
lastMaintenanceAt = Date()
+55 -7
View File
@@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
private var heartbeatGeneration: UInt64 = 0
private var started = false
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
@@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
/// Start the service (safe to call multiple times)
func start() {
guard !started else { return }
started = true
heartbeatGeneration &+= 1
SecureLogger.info("Presence: service starting...", category: .session)
scheduleNextHeartbeat()
}
/// Stops the timer and every decorrelation task synchronously at the panic
/// boundary. Generation checks also protect against custom sleepers that
/// ignore task cancellation and return later.
func stopForPanic() {
started = false
heartbeatGeneration &+= 1
heartbeatTimer?.invalidate()
heartbeatTimer = nil
pendingBroadcastTasks.values.forEach { $0.cancel() }
pendingBroadcastTasks.removeAll(keepingCapacity: false)
}
private func setupObservers() {
// Monitor location channel changes
locationChanges
@@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
}
func handleLocationChange() {
guard started else { return }
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate()
// Small delay to allow location state to settle
let generation = heartbeatGeneration
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
guard let self,
self.started,
self.heartbeatGeneration == generation else { return }
self.performHeartbeat()
}
}
}
func handleConnectivityChange() {
guard started else { return }
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
@@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
}
func scheduleNextHeartbeat() {
guard started else { return }
heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
let generation = heartbeatGeneration
heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
guard let self,
self.started,
self.heartbeatGeneration == generation else { return }
self.performHeartbeat()
}
}
}
func performHeartbeat() {
guard started else { return }
let generation = heartbeatGeneration
// Always schedule next loop first ensures continuity even if this one fails/skips
defer { scheduleNextHeartbeat() }
defer {
if started, heartbeatGeneration == generation {
scheduleNextHeartbeat()
}
}
// 1. Check preconditions
guard torIsReady() else {
@@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
}
// Launch independent task for each channel's delay
Task { @MainActor in
// Random delay for decorrelation
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let taskID = UUID()
let sleeper = self.sleeper
let delay = TimeInterval.random(
in: burstMinDelay...burstMaxDelay
)
let nanoseconds = UInt64(delay * 1_000_000_000)
await self.sleeper(nanoseconds)
let task = Task { @MainActor [weak self] in
// Random delay for decorrelation
await sleeper(nanoseconds)
guard let self else { return }
guard !Task.isCancelled,
self.started,
self.heartbeatGeneration == generation else {
self.pendingBroadcastTasks.removeValue(forKey: taskID)
return
}
self.pendingBroadcastTasks.removeValue(forKey: taskID)
self.broadcastPresence(for: channel.geohash)
}
pendingBroadcastTasks[taskID] = task
}
}
+103 -13
View File
@@ -11,6 +11,13 @@ import BitFoundation
import Foundation
import Security
enum KeychainInstallLifecycleAction: Equatable {
case markerPresent
case bootstrapMarker
case clearStaleKeys
case retryLater
}
final class KeychainManager: KeychainManagerProtocol {
/// Default keychain for components that construct their own rather than
/// having one injected. Under test this is an in-memory keychain: the
@@ -41,27 +48,80 @@ final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
// device locked (identity-cache saves failed with -25308 throughout
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
// restoration must be able to read the noise keys before the user
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
// unlocks. ThisDeviceOnly prevents private identities and group keys from
// migrating through device backups onto a second device.
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
init() {
#if os(iOS)
reconcileInstallLifecycle()
migrateAccessibilityIfNeeded()
#endif
}
static func installLifecycleAction(
containerKnowsMarker: Bool,
markerRead: KeychainReadResult
) -> KeychainInstallLifecycleAction {
switch markerRead {
case .success:
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
case .itemNotFound:
return .bootstrapMarker
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
return .retryLater
}
}
#if os(iOS)
private static let installMarkerAccount = "install_lifecycle_marker"
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
/// Keychain items can survive app removal while the app container and its
/// UserDefaults do not. The first version carrying this marker bootstraps
/// without deleting existing users' identities. On a later reinstall, a
/// surviving keychain marker plus a missing defaults marker proves the app
/// container was replaced, so stale secrets are removed before use.
private func reconcileInstallLifecycle() {
let defaults = UserDefaults.standard
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
switch Self.installLifecycleAction(
containerKnowsMarker: containerKnowsMarker,
markerRead: markerRead
) {
case .markerPresent:
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
case .bootstrapMarker:
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
}
case .clearStaleKeys:
_ = deleteAllKeychainData()
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
case .retryLater:
// Do not guess that a temporarily unreadable marker is absent.
// Leaving the defaults flag unchanged lets a later construction
// retry once protected keychain data is available.
break
}
}
/// One-time upgrade of items created under WhenUnlocked. New saves get
/// the right class on their own (saves are delete-then-add), but the
/// long-lived identity keys are written once and would otherwise stay
/// unreadable while the device is locked.
private func migrateAccessibilityIfNeeded() {
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
guard !UserDefaults.standard.bool(forKey: flag) else { return }
let query: [String: Any] = [
@@ -76,7 +136,7 @@ final class KeychainManager: KeychainManagerProtocol {
case errSecSuccess, errSecItemNotFound:
// Nothing to migrate on a fresh install; both are terminal.
UserDefaults.standard.set(true, forKey: flag)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
default:
// Likely errSecInteractionNotAllowed (relaunched while locked)
// leave the flag unset so the next launch retries.
@@ -492,6 +552,15 @@ final class KeychainManager: KeychainManagerProtocol {
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
#if os(iOS)
// The non-secret marker is intentionally recreated after a panic so a
// later uninstall/reinstall can still be distinguished from an in-place
// upgrade. It never restores any wiped identity or relationship data.
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
UserDefaults.standard.set(true, forKey: Self.installMarkerDefaultsKey)
}
#endif
return totalDeleted > 0
}
@@ -526,18 +595,39 @@ final class KeychainManager: KeychainManagerProtocol {
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
var query: [String: Any] = [
let primaryKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecValueData as String: data
kSecAttrAccount as String: key
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
var addQuery = primaryKeyQuery
addQuery.merge([
kSecValueData as String: data,
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
kSecAttrSynchronizable as String: false
]) { _, new in new }
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
// Delete by the item's primary key only. Value/accessibility fields
// are add attributes, not valid selectors for replacing an existing
// item; including them can leave the old item in place and make the
// subsequent add fail as a duplicate.
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(deleteStatus)),
context: "Unable to replace custom-service keychain item",
category: .keychain
)
return
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
SecureLogger.error(
NSError(domain: "Keychain", code: Int(addStatus)),
context: "Unable to save custom-service keychain item",
category: .keychain
)
}
}
/// Load data from a custom service
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
// For very long content, use plain formatting to avoid expensive
// regex/detector work. Cashu presence must not disable this guard.
if content.isOversizedForRichFormatting() {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
@@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables)
}
/// Stops all internet-facing work at the synchronous panic boundary.
/// `start()` may be called again only after the full wipe commits.
func stopForPanic() {
cancellables.removeAll()
started = false
reachabilityMonitor.stop()
activationAllowed = false
torAutoStartDesired = false
relayController.disconnect()
torController.setAutoStartAllowed(false)
applyTorState(torDesired: false)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
@@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject {
}
private func reevaluate() {
guard started else { return }
let allowed = effectiveAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
@@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
/// Begin monitoring. Idempotent.
func start()
/// Stop monitoring and discard pending debounce work. Idempotent.
func stop()
}
/// Pure debounce/decision logic for reachability, split out so it can be
@@ -98,6 +100,7 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
Empty(completeImmediately: false).eraseToAnyPublisher()
}
func start() {}
func stop() {}
}
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
@@ -146,6 +149,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
#endif
}
func stop() {
guard started else { return }
started = false
flushWorkItem?.cancel()
flushWorkItem = nil
#if canImport(Network)
monitor?.pathUpdateHandler = nil
monitor?.cancel()
monitor = nil
#endif
}
/// Feed an observation into the debounce and publish committed changes.
/// Exposed internally so higher layers/tests could drive it if needed.
func ingest(reachable: Bool) {
+12
View File
@@ -46,6 +46,7 @@ enum TransportConfig {
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let geoNicknameParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
@@ -81,6 +82,11 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Reject oversized/untrusted relay frames before JSON parse / store.
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
@@ -98,6 +104,12 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// Bound attacker-keyed bucket maps (sender IDs / content digests).
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// Cap teleported-participant markers so remote events cannot grow the set.
static let geoTeleportedParticipantsCap: Int = 1337
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
+40
View File
@@ -0,0 +1,40 @@
import Foundation
/// In-app override for the UI language, on top of the system per-app
/// language. Apple resolves localization from the AppleLanguages default at
/// process start, so a new choice takes effect on the next launch callers
/// surface a "restart to apply" note after changing it.
enum AppLanguageSettings {
/// "" means no override: follow the device (or per-app system) language.
static let overrideKey = "app.languageOverride"
private static let appleLanguagesKey = "AppleLanguages"
/// Language codes the app ships translations for, straight from the
/// built bundle so this never drifts from the string catalog.
static var availableLanguages: [String] {
Bundle.main.localizations
.filter { $0 != "Base" }
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
}
/// The language's name in that language ("فارسی", "") so every user
/// can find their own entry regardless of the current UI language.
static func endonym(for code: String) -> String {
let locale = Locale(identifier: code)
let name = locale.localizedString(forIdentifier: code) ?? code
return name.lowercased(with: locale)
}
/// Persists the override (nil clears it). AppleLanguages drives the
/// actual localization lookup on next launch.
static func setOverride(_ code: String?) {
let defaults = UserDefaults.standard
if let code, !code.isEmpty {
defaults.set(code, forKey: overrideKey)
defaults.set([code], forKey: appleLanguagesKey)
} else {
defaults.removeObject(forKey: overrideKey)
defaults.removeObject(forKey: appleLanguagesKey)
}
}
}
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
assemblies.values.contains { $0.messageID == message.id }
}
/// Stop every live file handle/player before the panic media directory is
/// removed. This prevents an in-flight assembly from continuing to write
/// through an unlinked file after the wipe returns.
func resetForPanic() {
for assembly in Array(assemblies.values) {
cancelAssembly(assembly)
}
for player in drainingPlayers.values {
player.stop()
}
drainingPlayers.removeAll(keepingCapacity: false)
finishedBursts.removeAll(keepingCapacity: false)
updatePublicTalkerIndicator()
}
/// Called for every inbound private message: when it is the finalized
/// voice note of a burst we assembled (matched by burst ID in the file
/// name), swap it into the existing live bubble and report `true` so the
@@ -78,6 +78,7 @@ final class ChatMediaTransferCoordinator {
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
private var preparationGeneration: UInt64 = 0
init(context: any ChatMediaTransferContext) {
self.context = context
@@ -98,13 +99,14 @@ final class ChatMediaTransferCoordinator {
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
let generation = preparationGeneration
Task.detached(priority: .userInitiated) { [weak self] in
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
@@ -116,13 +118,13 @@ final class ChatMediaTransferCoordinator {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
}
}
@@ -132,11 +134,15 @@ final class ChatMediaTransferCoordinator {
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
let generation = preparationGeneration
Task.detached { [weak self] in
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else {
try? FileManager.default.removeItem(at: processedURL)
return
}
self.sendImage(from: processedURL)
}
} catch {
@@ -147,11 +153,15 @@ final class ChatMediaTransferCoordinator {
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
let generation = preparationGeneration
Task.detached { [weak self] in
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else {
try? FileManager.default.removeItem(at: processedURL)
return
}
self.sendImage(from: processedURL)
}
} catch {
@@ -170,6 +180,7 @@ final class ChatMediaTransferCoordinator {
}
let targetPeer = context.selectedPrivateChatPeer
let generation = preparationGeneration
do {
try ImageUtils.validateImageSource(at: sourceURL)
@@ -184,7 +195,10 @@ final class ChatMediaTransferCoordinator {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else {
try? FileManager.default.removeItem(at: prepared.outputURL)
return
}
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer
@@ -201,13 +215,13 @@ final class ChatMediaTransferCoordinator {
} catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.context.addSystemMessage("Image is too large to send.")
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
guard let self, self.preparationGeneration == generation else { return }
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
@@ -341,6 +355,19 @@ final class ChatMediaTransferCoordinator {
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
/// Invalidates detached preparation work and cancels every transfer that
/// reached the transport. Stale tasks check the generation before they
/// can recreate a message or send after a panic wipe.
func resetForPanic() {
preparationGeneration &+= 1
let transferIDs = Set(transferIdToMessageIDs.keys)
transferIdToMessageIDs.removeAll(keepingCapacity: false)
messageIDToTransferId.removeAll(keepingCapacity: false)
for transferID in transferIDs {
context.cancelTransfer(transferID)
}
}
}
private extension ChatMediaTransferCoordinator {
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
if content.isOversizedForRichFormatting() {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
+172 -69
View File
@@ -89,6 +89,26 @@ import UIKit
#endif
import UniformTypeIdentifiers
struct PanicNetworkLifecycle {
let stop: @MainActor () -> Void
let restart: @MainActor () -> Void
static let noop = PanicNetworkLifecycle(stop: {}, restart: {})
static var live: PanicNetworkLifecycle {
PanicNetworkLifecycle(
stop: {
GeohashPresenceService.shared.stopForPanic()
NetworkActivationService.shared.stopForPanic()
},
restart: {
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
}
)
}
}
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
@@ -142,6 +162,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var currentColorScheme: ColorScheme = .light
@Published var currentTheme: AppTheme = .matrix
@Published var isConnected = false
@Published private(set) var panicRecoveryBlocked = false
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
@Published var nickname: String = "" {
didSet {
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
@@ -151,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
return
}
// Update mesh service nickname if it's initialized
if !meshService.myPeerID.isEmpty {
if !isPanicResetting, !meshService.myPeerID.isEmpty {
meshService.setNickname(nickname)
}
}
@@ -177,7 +199,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
context: self,
sweepsOnInit: !TestEnvironment.isRunningTests
)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
@@ -292,6 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var nostrRelayManager: NostrRelayManager?
private let userDefaults = UserDefaults.standard
let keychain: KeychainManagerProtocol
private let panicRecoveryOperations: PanicRecoveryOperations
private let panicNetworkLifecycle: PanicNetworkLifecycle
private var isPanicResetting = false
/// Private group membership: keys in the keychain, metadata on disk.
let groupStore: GroupStore
private let nicknameKey = "bitchat.nickname"
@@ -769,7 +797,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
let startSuspendedForRecovery: Bool
do {
startSuspendedForRecovery =
try livePanicRecoveryOperations.isPending()
} catch {
startSuspendedForRecovery = true
}
// Preserve the preflight decision used to defer CoreBluetooth. A
// transiently successful second read must not skip recovery and leave
// the service permanently suspended without running the wipe.
let panicRecoveryOperations = PanicRecoveryOperations(
isPending: {
if startSuspendedForRecovery {
return true
}
return try livePanicRecoveryOperations.isPending()
},
begin: livePanicRecoveryOperations.begin,
wipeMedia: livePanicRecoveryOperations.wipeMedia,
complete: livePanicRecoveryOperations.complete
)
let meshService = BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
startSuspendedForPanicRecovery: startSuspendedForRecovery
)
meshService.sfMetrics = .shared
self.init(
keychain: keychain,
@@ -781,7 +836,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager,
outboxStore: MessageOutboxStore(keychain: keychain),
sfMetrics: .shared
sfMetrics: .shared,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: .live
)
}
@@ -799,7 +856,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationManager: LocationChannelManager = .shared,
readReceiptsDefaults: UserDefaults? = nil,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
sfMetrics: StoreAndForwardMetrics? = nil,
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
) {
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -814,6 +874,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
self.keychain = keychain
self.panicRecoveryOperations = panicRecoveryOperations
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
self.panicNetworkLifecycle = panicNetworkLifecycle
self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
@@ -849,8 +912,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
.store(in: &cancellables)
let recoveryRequired: Bool
do {
recoveryRequired = try self.panicRecoveryOperations.isPending()
} catch {
// Failure to read the latch cannot fail open. Re-run the complete
// transaction; a persistent storage failure leaves services
// blocked below.
recoveryRequired = true
SecureLogger.error(
"Could not read panic-recovery state; retrying the full wipe before startup: \(error)",
category: .security
)
}
if recoveryRequired {
SecureLogger.warning(
"Pending panic recovery detected; wiping before runtime services start",
category: .security
)
_ = panicClearAllData(restartServices: false)
}
if networkActivationAllowed {
ChatViewModelBootstrapper(viewModel: self).configure()
}
}
// MARK: - Deinitialization
@@ -1153,8 +1240,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// PANIC: Emergency data clearing for activist safety
@MainActor
func panicClearAllData() {
// Messages are processed immediately - nothing to flush
@discardableResult
func panicClearAllData(restartServices: Bool = true) -> Bool {
panicRecoveryBlocked = true
isPanicResetting = true
defer { isPanicResetting = false }
// Stop internet and location-presence work before clearing identity or
// state. These services cancel their subscriptions and delayed tasks,
// so old callbacks cannot reconnect during the transaction.
panicNetworkLifecycle.stop()
// Establish both independent durable intents before erasing anything.
// `wipeMedia` will still attempt deletion if neither write succeeds.
let recoveryIntent = panicRecoveryOperations.begin()
// 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()
} else {
meshService.emergencyDisconnectAll()
}
// Invalidate detached media preparation and close live capture file
// handles before clearing state or removing the media directory.
mediaTransferCoordinator.resetForPanic()
liveVoiceCoordinator.resetForPanic()
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
@@ -1176,13 +1288,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname()
userDefaults.set(nickname, forKey: nicknameKey)
// Clear favorites and peer mappings
// Clear through SecureIdentityStateManager instead of directly
identityManager.clearAllIdentityData()
peerIdentityStore.clearAll()
locationPresenceStore.reset()
publicRateLimiter.reset()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
@@ -1247,78 +1360,68 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear Nostr identity associations
idBridge.clearAllAssociations()
// Disconnect from all peers and clear persistent identity
// This will force creation of a new identity (new fingerprint) on next launch
meshService.emergencyDisconnectAll()
// 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(currentNickname: nickname)
bleService.resetIdentityForPanic(
currentNickname: nickname,
restartServices: false
)
} else {
meshService.setNickname(nickname)
}
// No need to force UserDefaults synchronization
// The wipe must finish before this security action returns. A detached
// task could otherwise lose a race with a new capture or app exit and
// leave pre-panic media behind.
let panicCompleted: Bool
do {
try panicRecoveryOperations.wipeMedia(recoveryIntent)
try panicRecoveryOperations.complete()
panicCompleted = true
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
} catch {
panicCompleted = false
SecureLogger.error(
"Panic transaction did not commit; services remain stopped: \(error)",
category: .security
)
}
panicRecoveryBlocked = !panicCompleted
// Reinitialize Nostr with new identity
// This will generate new Nostr keys derived from new Noise keys.
// Skipped under tests: connecting the shared relay singleton starts
// real network/reconnect work that never completes and would keep the
// test process alive (the singleton, unlike a discardable instance, is
// never deallocated to cancel it).
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
// the host user's real cache tree just as the default media wipe does.
#if os(iOS)
if !TestEnvironment.isRunningTests {
Task { @MainActor in
// Small delay to ensure cleanup completes
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
Self.clearAppSwitcherSnapshots()
}
#endif
// Reinitialize Nostr relay manager with new identity. Reuse the
// shared singleton every other component (NostrTransport, geohash
// subscriptions, AppRuntime observers) is bound to `.shared`, so
// creating a fresh instance here would split relay state and leave
// sends running against a disconnected manager.
guard panicCompleted else { return false }
if let bleService = meshService as? BLEService {
// Startup recovery reopens admission but leaves actual service
// start to the bootstrapper immediately after this method.
bleService.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) {
meshService.startServices()
}
if !TestEnvironment.isRunningTests {
nostrRelayManager = NostrRelayManager.shared
setupNostrMessageHandling()
nostrRelayManager?.connect()
}
panicNetworkLifecycle.restart()
}
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// utility-priority wipe fires at a nondeterministic time
// deleting media that concurrently running tests (e.g. the
// sendImage flow) just wrote there, and the developer's real
// app data with it.
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
// Delete the entire files directory and recreate it
if FileManager.default.fileExists(atPath: filesDir.path) {
try FileManager.default.removeItem(at: filesDir)
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
}
// Recreate empty directory structure
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
} catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
}
// BCH-01-013: Clear iOS app switcher snapshots
// These are stored in Library/Caches/Snapshots/<bundle_id>/
#if os(iOS)
Self.clearAppSwitcherSnapshots()
#endif
}
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed
return true
}
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
@@ -156,6 +156,17 @@ private extension ChatViewModelBootstrapper {
viewModel?.objectWillChange.send()
}
.store(in: &viewModel.cancellables)
viewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] people in
Task { @MainActor [weak viewModel] in
let visible = Set(people.map { $0.id })
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
}
}
.store(in: &viewModel.cancellables)
}
func loadPersistedViewState() {
+79 -8
View File
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
}
return false
}
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
}
private var senderBuckets: [String: TokenBucket] = [:]
@@ -35,17 +39,26 @@ struct MessageRateLimiter {
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
contentRefillPerSec: Double,
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
self.maxSenderBuckets = max(1, maxSenderBuckets)
self.maxContentBuckets = max(1, maxContentBuckets)
self.bucketIdleTTL = bucketIdleTTL
}
/// - Parameter powBits: validated NIP-13 difficulty of the event
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
if powBits >= NostrPoW.rateLimitBypassBits {
senderAllowed = true
} else {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
var senderBucket = Self.bucket(
for: senderKey,
in: &senderBuckets,
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
maxBuckets: maxSenderBuckets,
idleTTL: bucketIdleTTL,
now: now
)
senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
}
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
// Rejected senders must not mint attacker-keyed content entries.
guard senderAllowed else { return false }
var contentBucket = Self.bucket(
for: contentKey,
in: &contentBuckets,
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
maxBuckets: maxContentBuckets,
idleTTL: bucketIdleTTL,
now: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return senderAllowed && contentAllowed
return contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
var bucketCountsForTesting: (sender: Int, content: Int) {
(senderBuckets.count, contentBuckets.count)
}
// Static so we can take `inout` on a stored dictionary without overlapping
// exclusive access through a mutating method on `self`.
private static func bucket(
for key: String,
in buckets: inout [String: TokenBucket],
capacity: Double,
refillPerSec: Double,
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) -> TokenBucket {
if let existing = buckets[key] {
return existing
}
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
return TokenBucket(
capacity: capacity,
tokens: capacity,
refillPerSec: refillPerSec,
lastRefill: now
)
}
private static func evictIfNeeded(
from buckets: inout [String: TokenBucket],
maxBuckets: Int,
idleTTL: TimeInterval,
now: Date
) {
guard buckets.count >= maxBuckets else { return }
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
guard buckets.count >= maxBuckets else { return }
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
buckets.removeValue(forKey: oldestKey)
}
}
}
@@ -196,7 +196,10 @@ final class NostrInboundPipeline {
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
SecureLogger.debug(
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
category: .session
)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
@@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject {
Task {
let finalDuration = Date().timeIntervalSince(startDate)
if let url = await session.finish(),
isValidRecording(at: url, duration: finalDuration) {
if let url = await session.finish() {
// Panic and a newer hold both invalidate this completion.
// Never route an old recording using a post-panic target.
guard generation == holdGeneration else {
try? FileManager.default.removeItem(at: url)
return
}
guard isValidRecording(
at: url,
duration: finalDuration
) else {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
)
return
}
completion(url)
} else {
guard generation == holdGeneration, state == .idle else { return }
@@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject {
finish(completion: nil)
}
/// Invalidates in-flight permission/start/finalize callbacks and tears
/// down an active microphone before the panic transaction continues.
func panicWipe() {
holdGeneration &+= 1
let session = activeSession
activeSession = nil
state = .idle
isLiveStreaming = false
session?.panicCancelSynchronously()
}
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
+73
View File
@@ -26,6 +26,10 @@ struct AppInfoView: View {
/// introduction), and afterwards the sheet reopens wherever it was left.
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
@State private var showPanicConfirmation = false
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
/// The override changed this session; localization resolves at process
/// start, so surface the restart hint.
@State private var showLanguageRestartNote = false
private enum Pane: String {
case settings
@@ -55,6 +59,11 @@ struct AppInfoView: View {
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
static func bridgeCell(_ cell: String) -> String {
@@ -313,6 +322,52 @@ struct AppInfoView: View {
}
}
// Language an in-app override so the UI language can differ
// from the device language (takes effect on next launch).
VStack(alignment: .leading, spacing: 12) {
SectionHeader(verbatim: Strings.Settings.languageTitle)
settingsCard {
Menu {
Button {
selectLanguage(nil)
} label: {
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
}
Divider()
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
Button {
selectLanguage(code)
} label: {
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
}
}
} label: {
HStack {
Text(Strings.Settings.languagePickerLabel)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(textColor)
Spacer()
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
.bitchatFont(size: 12)
.foregroundColor(palette.accent)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showLanguageRestartNote {
Text(Strings.Settings.languageRestartNote)
.bitchatFont(size: 11)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// Voice same card + IRC pill as every other toggle setting.
VStack(alignment: .leading, spacing: 12) {
SectionHeader(Strings.Voice.title)
@@ -458,6 +513,24 @@ struct AppInfoView: View {
.padding()
}
private func selectLanguage(_ code: String?) {
let previous = languageOverride
AppLanguageSettings.setOverride(code)
languageOverride = code ?? ""
if languageOverride != previous {
showLanguageRestartNote = true
}
}
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
HStack {
Text(title)
if isSelected {
Image(systemName: "checkmark")
}
}
}
private var bridgeToggleBinding: Binding<Bool> {
Binding(
get: { bridgeService.isEnabled },
@@ -41,7 +41,7 @@ struct TextMessageView: View {
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isLong = message.content.isLongForDisplay()
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
@@ -103,7 +103,7 @@ struct TextMessageView: View {
}
// Expand/Collapse for very long messages
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
if message.content.isLongForDisplay() {
let isExpanded = expandedMessageIDs.contains(message.id)
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) {
+4
View File
@@ -79,6 +79,9 @@ struct ContentView: View {
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
}
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
voiceRecordingVM?.panicWipe()
}
#if os(macOS)
DispatchQueue.main.async {
isNicknameFieldFocused = false
@@ -229,6 +232,7 @@ struct ContentView: View {
}
.onDisappear {
autocompleteDebounceTimer?.invalidate()
appChromeModel.setPanicPreparation(nil)
}
}
+20
View File
@@ -21,6 +21,26 @@ extension String {
return current >= threshold
}
/// True when the message should collapse behind Show more in the UI.
/// Length alone decides this embedding a Cashu-looking token must not
/// disable the guard (remote DoS via unbounded layout).
func isLongForDisplay(
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
/// True when rich formatting (regex / link detectors) should be skipped.
/// Cashu presence used to exempt oversized content from the plain path;
/// that let untrusted input force expensive formatting work.
func isOversizedForRichFormatting(
lengthThreshold: Int = 4000,
tokenThreshold: Int = 1024
) -> Bool {
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
}
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
@@ -38,6 +38,12 @@
"comment" : "Fallback title when saving a shared link"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیوند اشتراک‌گذاری‌شده"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -233,6 +239,12 @@
"comment" : "Shown when the share payload cannot be encoded"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کدگذاری پیوند ناموفق بود"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -428,6 +440,12 @@
"comment" : "Shown when provided content cannot be shared"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "محتوای قابل اشتراک‌گذاری وجود ندارد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -623,6 +641,12 @@
"comment" : "Shown when the share extension receives no content"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "چیزی برای اشتراک‌گذاری نیست"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -818,6 +842,12 @@
"comment" : "Confirmation after successfully sharing a link"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -1013,6 +1043,12 @@
"comment" : "Confirmation after successfully sharing text"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
+38
View File
@@ -147,6 +147,44 @@ struct AppArchitectureTests {
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
@MainActor
func locationPresenceStoreBoundsTeleportedParticipants() {
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.markTeleported("AAAAAA")
store.markTeleported("BBBBBB")
store.markTeleported("CCCCCC")
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
#expect(store.teleportedGeo == Set(["cccccc"]))
store.setCurrentGeohash("u4pruz")
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
@MainActor
func locationPresenceStoreBoundsGeoNicknames() {
let store = LocationPresenceStore(geoNicknameCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.setNickname("alice", for: "AAAAAA")
store.setNickname("bob", for: "BBBBBB")
store.setNickname("carol", for: "CCCCCC")
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
#expect(store.geoNicknames == ["cccccc": "carol"])
store.setCurrentGeohash("u4pruz")
#expect(store.geoNicknames.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
+20
View File
@@ -572,6 +572,26 @@ struct BLEServiceCoreTests {
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
}
@Test
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
let ble = makeService()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let packet = makePublicPacket(
content: "late callback",
sender: ble.myPeerID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
)
ble.suspendForPanicReset()
ble.sendPacket(packet)
#expect(outbound.count(ofType: .message) == 0)
ble.completePanicReset(restartServices: false)
ble.sendPacket(packet)
#expect(outbound.count(ofType: .message) == 1)
}
@Test
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
@@ -188,6 +188,21 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
coordinator.resetForPanic()
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
+114 -2
View File
@@ -15,7 +15,11 @@ import BitFoundation
/// Creates a ChatViewModel with mock dependencies for testing
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
private func makeTestableViewModel(
panicMediaWipe: (() throws -> Void)? = nil,
panicRecoveryOperations: PanicRecoveryOperations? = nil,
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
) -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
@@ -26,7 +30,10 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
transport: transport,
panicMediaWipe: panicMediaWipe,
panicRecoveryOperations: panicRecoveryOperations,
panicNetworkLifecycle: panicNetworkLifecycle
)
return (viewModel, transport)
@@ -646,6 +653,25 @@ struct ChatViewModelFormattingTests {
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageAsText_longCashuFallsBackToPlain() async {
let (viewModel, _) = makeTestableViewModel()
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "fmt-long-cashu",
sender: "Alice#a1b2",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
isRelay: false,
senderPeerID: PeerID(str: "00000000000000b3")
)
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
}
@Test @MainActor
func formatMessageHeader_formatsSenderHeader() async {
let (viewModel, _) = makeTestableViewModel()
@@ -1097,6 +1123,92 @@ struct ChatViewModelBluetoothTests {
struct ChatViewModelPanicTests {
@Test @MainActor
func panicClearAllData_finishesMediaWipeBeforeReturning() {
var wipeFinished = false
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
wipeFinished = true
})
viewModel.panicClearAllData()
#expect(wipeFinished)
}
@Test @MainActor
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
var events: [String] = []
let lifecycle = PanicNetworkLifecycle(
stop: { events.append("stop") },
restart: { events.append("restart") }
)
let (viewModel, _) = makeTestableViewModel(
panicMediaWipe: { events.append("wipe") },
panicNetworkLifecycle: lifecycle
)
let completed = viewModel.panicClearAllData()
#expect(completed)
#expect(events == ["stop", "wipe", "restart"])
#expect(viewModel.networkActivationAllowed)
}
@Test @MainActor
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
var events: [String] = []
let operations = PanicRecoveryOperations(
isPending: {
events.append("read")
return true
},
begin: {
events.append("begin")
return PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in events.append("wipe") },
complete: { events.append("complete") }
)
let (viewModel, transport) = makeTestableViewModel(
panicRecoveryOperations: operations
)
#expect(events == ["read", "begin", "wipe", "complete"])
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 1)
#expect(viewModel.networkActivationAllowed)
}
@Test @MainActor
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
enum WipeFailure: Error { case failed }
var completedMarker = false
let operations = PanicRecoveryOperations(
isPending: { true },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: true,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in throw WipeFailure.failed },
complete: { completedMarker = true }
)
let (viewModel, transport) = makeTestableViewModel(
panicRecoveryOperations: operations
)
#expect(!completedMarker)
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(transport.startServicesCallCount == 0)
#expect(!viewModel.networkActivationAllowed)
}
@Test @MainActor
func panicClearAllData_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
-424
View File
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
))
}
/// Deliberately simple O(n) model used to differentially test the store's
/// optimized logical-index bookkeeping. It models observable behavior only;
/// it has no offset or ID index and therefore cannot reproduce the same bug.
private struct ReferenceConversationTimeline {
struct Message: Equatable {
let id: String
let timestamp: Date
let content: String
var deliveryStatus: DeliveryStatus?
init(_ message: BitchatMessage) {
id = message.id
timestamp = message.timestamp
content = message.content
deliveryStatus = message.deliveryStatus
}
}
struct AppendResult {
let inserted: Bool
let trimmedCount: Int
}
let cap: Int
private(set) var messages: [Message] = []
func contains(_ id: String) -> Bool {
messages.contains { $0.id == id }
}
mutating func append(_ message: BitchatMessage) -> AppendResult {
guard !contains(message.id) else {
return AppendResult(inserted: false, trimmedCount: 0)
}
let snapshot = Message(message)
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp <= snapshot.timestamp {
low = mid + 1
} else {
high = mid
}
}
messages.insert(snapshot, at: low)
let overflow = max(0, messages.count - cap)
if overflow > 0 {
messages.removeFirst(overflow)
}
return AppendResult(inserted: true, trimmedCount: overflow)
}
mutating func upsert(_ message: BitchatMessage) -> Int {
if let index = messages.firstIndex(where: { $0.id == message.id }) {
messages[index] = Message(message)
return 0
}
return append(message).trimmedCount
}
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
guard let index = messages.firstIndex(where: { $0.id == id }),
messages[index].deliveryStatus != status else {
return false
}
// The differential stream uses only unique `.delivered` values (or
// an exact repeat), so no-downgrade policy is intentionally outside
// this index-focused reference model.
messages[index].deliveryStatus = status
return true
}
mutating func remove(at index: Int) -> Message {
messages.remove(at: index)
}
mutating func removeAll(where predicate: (Message) -> Bool) {
messages.removeAll(where: predicate)
}
mutating func clear() {
messages.removeAll()
}
}
private struct ConversationStoreDifferentialRNG {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
return value ^ (value >> 31)
}
mutating func index(upperBound: Int) -> Int {
precondition(upperBound > 0)
return Int(next() % UInt64(upperBound))
}
}
@MainActor
private func expectStore(
_ store: ConversationStore,
matches reference: ReferenceConversationTimeline,
issuedIDs: [String],
checkpoint: String
) {
let conversation = store.conversation(for: .mesh)
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
let lookupSnapshot = reference.messages.compactMap { expected in
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
}
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
#expect(
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
"per-conversation ID set mismatch at \(checkpoint)"
)
if !reference.messages.isEmpty {
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
let id = reference.messages[index].id
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
}
}
let activeIDs = Set(reference.messages.map(\.id))
var checkedStaleIDs = 0
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
checkedStaleIDs += 1
if checkedStaleIDs == 16 { break }
}
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
}
@Suite("ConversationStore")
struct ConversationStoreTests {
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
}
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
@MainActor
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
let store = ConversationStore()
let conversation = store.conversation(for: .mesh)
let overflow = 64
for i in 0..<(conversation.cap + overflow) {
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
}
#expect(conversation.messages.first?.id == "m\(overflow)")
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
// Exercise a suffix reindex after the head offset has advanced, then
// trim the old head. The late row becomes the new first element.
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
#expect(store.append(late, to: .mesh))
#expect(conversation.messages.first?.id == "late")
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
// Head and middle removals, an in-place upsert, and a status update
// must all resolve through the same logical index representation.
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
let middleID = "m\(overflow + conversation.cap / 2)"
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
let probeID = "m\(overflow + 10)"
store.upsertByID(
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
in: .mesh
)
#expect(conversation.message(withID: probeID)?.content == "edited")
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
#expect(store.auditInvariants().isEmpty)
// Clearing resets the logical offset as well as the maps.
store.clear(.mesh)
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
#expect(store.auditInvariants().isEmpty)
}
@Test("logical index offset matches a reference model under adversarial mutations")
@MainActor
func logicalIndexOffsetDifferentialStress() async {
let store = ConversationStore()
let cap = store.conversation(for: .mesh).cap
var reference = ReferenceConversationTimeline(cap: cap)
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
var issuedIDs: [String] = []
var nextID = 0
var nextTailTimestamp: TimeInterval = 1_700_000_000
var trimmedCount = 0
var tailAppendCount = 0
var outOfOrderCount = 0
var duplicateOrReuseCount = 0
var headRemovalCount = 0
var middleRemovalCount = 0
var upsertCount = 0
var deliveryUpdateCount = 0
var filterCount = 0
var clearCount = 0
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
let number = nextID
nextID += 1
let id = "diff-\(number)"
issuedIDs.append(id)
let resolvedTimestamp: TimeInterval
if let timestamp {
resolvedTimestamp = timestamp
} else {
resolvedTimestamp = nextTailTimestamp
nextTailTimestamp += 1
}
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
return makeMessage(
id: id,
timestamp: resolvedTimestamp,
content: "\(tag) \(number)\(dropMarker)"
)
}
@discardableResult
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
let expected = reference.append(message)
let actual = store.append(message, to: .mesh)
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
trimmedCount += expected.trimmedCount
return expected
}
func refill(extra: Int, checkpoint: String) async {
let appendCount = max(0, cap - reference.messages.count) + extra
for index in 0..<appendCount {
appendAndCompare(
issueMessage(tag: "refill"),
checkpoint: "\(checkpoint)-\(index)"
)
if index.isMultiple(of: 64) {
await Task.yield()
}
}
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
}
// Start well into steady state so the offset is already non-zero
// before any mixed operations begin.
await refill(extra: 384, checkpoint: "initial steady-state fill")
for step in 0..<1_200 {
if step == 300 || step == 900 {
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
reference.removeAll { $0.content.contains("[drop]") }
filterCount += 1
expectStore(
store,
matches: reference,
issuedIDs: issuedIDs,
checkpoint: "filter at step \(step)"
)
}
if step == 600 {
store.clear(.mesh)
reference.clear()
clearCount += 1
expectStore(
store,
matches: reference,
issuedIDs: issuedIDs,
checkpoint: "clear at step \(step)"
)
}
switch rng.index(upperBound: 100) {
case 0..<35:
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
tailAppendCount += 1
case 35..<55:
if reference.messages.isEmpty {
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
} else {
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
appendAndCompare(
issueMessage(timestamp: timestamp, tag: "out-of-order"),
checkpoint: "out-of-order append \(step)"
)
outOfOrderCount += 1
}
case 55..<65:
if issuedIDs.isEmpty {
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
} else {
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
let message = makeMessage(
id: reusedID,
timestamp: nextTailTimestamp,
content: "duplicate-or-trimmed-reuse \(step)"
)
nextTailTimestamp += 1
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
duplicateOrReuseCount += 1
}
case 65..<73:
if !reference.messages.isEmpty {
let expected = reference.remove(at: 0)
let actual = store.removeMessage(withID: expected.id, from: .mesh)
.map(ReferenceConversationTimeline.Message.init)
#expect(actual == expected, "head removal mismatch at step \(step)")
headRemovalCount += 1
}
case 73..<81:
if !reference.messages.isEmpty {
let middleStart = reference.messages.count / 4
let middleWidth = max(1, reference.messages.count / 2)
let index = min(
reference.messages.count - 1,
middleStart + rng.index(upperBound: middleWidth)
)
let expected = reference.remove(at: index)
let actual = store.removeMessage(withID: expected.id, from: .mesh)
.map(ReferenceConversationTimeline.Message.init)
#expect(actual == expected, "middle removal mismatch at step \(step)")
middleRemovalCount += 1
}
case 81..<90:
let message: BitchatMessage
if step.isMultiple(of: 4) || reference.messages.isEmpty {
let timestamp = reference.messages.isEmpty
? nil
: reference.messages[rng.index(upperBound: reference.messages.count)]
.timestamp.timeIntervalSince1970
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
} else {
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
message = makeMessage(
id: current.id,
timestamp: current.timestamp.timeIntervalSince1970,
content: "upsert-existing \(step)",
deliveryStatus: current.deliveryStatus
)
}
trimmedCount += reference.upsert(message)
store.upsertByID(message, in: .mesh)
upsertCount += 1
default:
let id: String
let repeatedStatus: DeliveryStatus?
if step.isMultiple(of: 6) || reference.messages.isEmpty {
id = "missing-\(step)"
repeatedStatus = nil
} else {
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
id = current.id
repeatedStatus = current.deliveryStatus
}
let status: DeliveryStatus
if step.isMultiple(of: 4), let repeatedStatus {
status = repeatedStatus
} else {
status = .delivered(
to: "peer",
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
)
}
let expected = reference.applyDeliveryStatus(status, to: id)
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
#expect(actual == expected, "delivery update mismatch at step \(step)")
deliveryUpdateCount += 1
}
expectStore(
store,
matches: reference,
issuedIDs: issuedIDs,
checkpoint: "mixed operation \(step)"
)
// This intentionally expensive MainActor stress test runs beside
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
// release the actor so their bounded waits can make progress.
await Task.yield()
if (step + 1).isMultiple(of: 100) {
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
}
}
// Guarantee another long run of one-row evictions after every other
// mutation family has perturbed and rebuilt the offset/index state.
await refill(extra: 512, checkpoint: "final steady-state trim run")
#expect(trimmedCount > 1_200)
#expect(tailAppendCount > 300)
#expect(outOfOrderCount > 150)
#expect(duplicateOrReuseCount > 75)
#expect(headRemovalCount > 50)
#expect(middleRemovalCount > 50)
#expect(upsertCount > 75)
#expect(deliveryUpdateCount > 75)
#expect(filterCount == 2)
#expect(clearCount == 1)
}
// MARK: - Upsert
@Test("upsertByID replaces in place and appends when absent")
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
// Exactly at threshold DOES trigger (uses >= comparison)
#expect(content.hasVeryLongToken(threshold: 50))
}
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
let cashu = "cashuA" + String(repeating: "a", count: 40)
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
#expect(content.extractCashuLinks().count == 1)
#expect(content.isLongForDisplay())
}
@MainActor
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
let context = MockMessageFormattingContext(nickname: "carol")
let cashu = "cashuA" + String(repeating: "a", count: 40)
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
let message = BitchatMessage(
id: "long-cashu",
sender: "alice",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
}
}
@MainActor
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
#expect(plain)
#expect(!plainExhausted)
}
@Test("Content buckets do not grow when sender is rate limited")
func contentBucketsDoNotGrowAfterSenderLimit() {
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: 10,
maxContentBuckets: 10,
bucketIdleTTL: 60
)
let now = Date()
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
var rejected = true
for index in 1...100 {
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
rejected = false
}
}
#expect(first)
#expect(rejected)
#expect(limiter.bucketCountsForTesting.sender == 1)
#expect(limiter.bucketCountsForTesting.content == 1)
}
@Test("Bucket maps evict entries at configured caps")
func bucketMapsEvictAtConfiguredCaps() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
for index in 0..<25 {
_ = limiter.allow(
senderKey: "sender-\(index)",
contentKey: "content-\(index)",
now: now.addingTimeInterval(TimeInterval(index))
)
}
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
@Test("PoW bypass still creates content buckets under the cap")
func powBypassCreatesBoundedContentBuckets() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 100,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
var allAllowed = true
for index in 0..<10 {
let allowed = limiter.allow(
senderKey: "sender",
contentKey: "content-\(index)",
powBits: NostrPoW.rateLimitBypassBits,
now: now.addingTimeInterval(TimeInterval(index))
)
if !allowed { allAllowed = false }
}
#expect(allAllowed)
#expect(limiter.bucketCountsForTesting.sender == 0)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
}
+58
View File
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
@Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array(
repeating: ["g", "u4pruyd"],
count: TransportConfig.nostrMaxEventTags + 1
)
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [Array(
repeating: "value",
count: TransportConfig.nostrMaxEventTagValues + 1
)]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [[
"g",
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
]]
#expect(throws: NostrError.invalidEvent) {
_ = try NostrEvent(from: eventDict)
}
}
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
let event = try NostrEvent(from: eventDict)
#expect(event.tags.count == 2)
}
// MARK: - Helpers
private static func validInboundEventDict() -> [String: Any] {
[
"id": String(repeating: "0", count: 64),
"pubkey": String(repeating: "1", count: 64),
"created_at": 1_234_567,
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
"tags": [["g", "u4pruyd"]],
"content": "hello",
"sig": String(repeating: "2", count: 128)
]
}
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
}
// MARK: - 7b. ConversationStore append at the retention cap
/// Steady-state public timeline traffic after the 1337-message retention
/// cap has been reached. Every tail append evicts the oldest row, which is
/// the long-lived workload the cold `store.append` benchmark does not
/// exercise.
func testConversationStoreSteadyStateAppend() {
let store = ConversationStore()
let cap = TransportConfig.meshTimelineCap
let messagesPerPass = 500
let base = Date(timeIntervalSince1970: 1_700_000_000)
for i in 0..<cap {
store.append(
BitchatMessage(
id: "perf-steady-seed-\(i)",
sender: "perfsender",
content: "steady-state seed \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
),
to: .mesh
)
}
var pass = 0
var samples: [TimeInterval] = []
measure {
let startIndex = cap + pass * messagesPerPass
let start = Date()
for offset in 0..<messagesPerPass {
let i = startIndex + offset
store.append(
BitchatMessage(
id: "perf-steady-\(i)",
sender: "perfsender",
content: "steady-state message \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
),
to: .mesh
)
}
samples.append(Date().timeIntervalSince(start))
pass += 1
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
}
reportThroughput(
"store.steadyStateAppend",
samples: samples,
operations: messagesPerPass,
unit: "messages"
)
}
// MARK: - 8. ConversationStore invariant audit (field observability)
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
@@ -30,10 +30,6 @@
"store.append": 213201,
"store.audit": 362
},
"_reference_local_numbers_2026_07": {
"store.steadyStateAppend_before": 2315,
"store.steadyStateAppend": 53976
},
"floors": {
"nostrInbound.fresh": 450,
"nostrInbound.duplicate": 250000,
@@ -45,7 +41,6 @@
"pipeline.privateIngest": 3000,
"pipeline.publicIngest": 2400,
"store.append": 48000,
"store.steadyStateAppend": 10000,
"store.audit": 70
},
"_slowest_observed_ci_numbers_2026_06": {
@@ -1,10 +1,31 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite("PreviewKeychainManager Tests")
struct PreviewKeychainManagerTests {
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
func installLifecycleDecision() {
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: true,
markerRead: .success(Data([1]))
) == .markerPresent)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .success(Data([1]))
) == .clearStaleKeys)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .itemNotFound
) == .bootstrapMarker)
#expect(KeychainManager.installLifecycleAction(
containerKnowsMarker: false,
markerRead: .deviceLocked
) == .retryLater)
}
@Test("Preview keychain manager stores identity and service-scoped data in memory")
func previewKeychainManagerRoundTripsData() {
let manager = PreviewKeychainManager()
@@ -370,6 +370,132 @@ struct BLEFileTransferHandlerTests {
#expect(!FileManager.default.fileExists(atPath: evictable.path))
}
@Test
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let subdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
"images/incoming",
"images/outgoing",
"files/incoming",
"files/outgoing"
]
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
}
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("legacy".utf8).write(to: unmanaged)
try store.panicWipe()
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
for subdirectory in subdirectories {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent(subdirectory, isDirectory: true)
var isDirectory: ObjCBool = false
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
#expect(isDirectory.boolValue)
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
}
}
@Test
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-marker-failure-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/images/outgoing", isDirectory: true)
.appendingPathComponent("secret.jpg")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
do {
try store.panicWipe(hasDurablePendingMarker: false)
Issue.record("Expected the missing durable marker to fail closed")
} catch {
// The marker error is reported only after the deletion attempt.
}
#expect(!FileManager.default.fileExists(atPath: secret.path))
#expect(
FileManager.default.fileExists(
atPath: secret.deletingLastPathComponent().path
)
)
}
@Test
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
enum MarkerFailure: Error { case unavailable }
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-external-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let secret = base
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
.appendingPathComponent("secret.m4a")
try FileManager.default.createDirectory(
at: secret.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: secret)
let store = BLEIncomingFileStore(
baseDirectory: base,
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
)
try store.panicWipe(hasDurablePendingMarker: true)
#expect(!FileManager.default.fileExists(atPath: secret.path))
}
@Test
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-recovery-marker-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
try store.markPanicRecoveryPending()
#expect(try store.isPanicRecoveryPending())
try store.panicWipe(hasDurablePendingMarker: true)
#expect(try store.isPanicRecoveryPending())
try store.completePanicRecovery()
#expect(try !store.isPanicRecoveryPending())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
burstMaxDelay: 0
)
service.start()
service.performHeartbeat()
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
XCTAssertEqual(sleptNanoseconds.count, 3)
XCTAssertEqual(scheduler.intervals, [17])
XCTAssertEqual(scheduler.intervals, [17, 17])
}
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
loopMaxInterval: 21
)
service.start()
service.performHeartbeat()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.intervals, [21])
XCTAssertEqual(scheduler.intervals, [21, 21])
}
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
loopMaxInterval: 22
)
service.start()
service.performHeartbeat()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.intervals, [22])
XCTAssertEqual(scheduler.intervals, [22, 22])
}
func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws {
let identity = try NostrIdentity.generate()
let scheduler = MockGeohashPresenceScheduler()
var sleeperContinuation: CheckedContinuation<Void, Never>?
var sendCount = 0
let service = makeService(
scheduler: scheduler,
deriveIdentity: { _ in identity },
relaySender: { _, _ in sendCount += 1 },
sleeper: { _ in
await withCheckedContinuation { continuation in
sleeperContinuation = continuation
}
},
burstMinDelay: 1,
burstMaxDelay: 1
)
service.start()
service.performHeartbeat()
let delayStarted = await waitUntil {
sleeperContinuation != nil
}
XCTAssertTrue(delayStarted)
service.stopForPanic()
sleeperContinuation?.resume()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(sendCount, 0)
XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1)
}
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
}
func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async {
let context = makeService(permission: .authorized, favorites: [])
context.service.start()
context.service.stopForPanic()
let connectCountAfterStop = context.relayController.connectCallCount
let startCountAfterStop = context.torController.startIfNeededCallCount
context.favoritesSubject.send([Data([0x01])])
context.reachability.set(false)
context.reachability.set(true)
try? await Task.sleep(nanoseconds: 30_000_000)
XCTAssertFalse(context.service.activationAllowed)
XCTAssertEqual(context.reachability.stopCallCount, 1)
XCTAssertEqual(context.torController.autoStartAllowedValues.last, false)
XCTAssertEqual(context.proxyController.proxyModes.last, false)
XCTAssertGreaterThanOrEqual(
context.torController.shutdownCompletelyCallCount,
1
)
XCTAssertGreaterThanOrEqual(
context.relayController.disconnectCallCount,
1
)
XCTAssertEqual(
context.relayController.connectCallCount,
connectCountAfterStop
)
XCTAssertEqual(
context.torController.startIfNeededCallCount,
startCountAfterStop
)
}
func test_start_afterPanicStop_reestablishesSubscriptions() {
let context = makeService(permission: .authorized, favorites: [])
context.service.start()
context.service.stopForPanic()
context.service.start()
XCTAssertTrue(context.service.activationAllowed)
XCTAssertEqual(context.reachability.startCallCount, 2)
XCTAssertEqual(context.relayController.connectCallCount, 2)
}
private func makeService(
permission: LocationChannelManager.PermissionState,
favorites: Set<Data>
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
let torController = MockNetworkActivationTorController()
let relayController = MockNetworkActivationRelayController()
let proxyController = MockNetworkActivationProxyController()
let reachability = MockNetworkActivationReachability()
let notificationCenter = NotificationCenter()
let service = NetworkActivationService(
storage: storage,
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
permissionProvider: { permissionSubject.value },
mutualFavoritesProvider: { favoritesSubject.value },
reachabilityMonitor: AlwaysReachableMonitor(),
reachabilityMonitor: reachability,
torController: torController,
relayController: relayController,
proxyController: proxyController,
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
service: service,
storage: storage,
favoritesSubject: favoritesSubject,
reachability: reachability,
torController: torController,
relayController: relayController,
proxyController: proxyController,
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
let service: NetworkActivationService
let storage: UserDefaults
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
let reachability: MockNetworkActivationReachability
let torController: MockNetworkActivationTorController
let relayController: MockNetworkActivationRelayController
let proxyController: MockNetworkActivationProxyController
let notificationCenter: NotificationCenter
}
@MainActor
private final class MockNetworkActivationReachability:
NetworkReachabilityMonitoring {
private let subject = CurrentValueSubject<Bool, Never>(true)
private(set) var startCallCount = 0
private(set) var stopCallCount = 0
var isReachable: Bool { subject.value }
var reachabilityPublisher: AnyPublisher<Bool, Never> {
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() {
startCallCount += 1
}
func stop() {
stopCallCount += 1
}
func set(_ reachable: Bool) {
subject.send(reachable)
}
}
@MainActor
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
private(set) var autoStartAllowedValues: [Bool] = []
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
}
func start() { startCalled = true }
func stop() { startCalled = false }
func set(_ reachable: Bool) { subject.send(reachable) }
}
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
private let startError: Error?
private(set) var finishStarted = false
private(set) var cancelCount = 0
private(set) var panicCancelCount = 0
private var finishContinuation: CheckedContinuation<URL?, Never>?
init(startError: Error? = nil) {
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
cancelCount += 1
}
func panicCancelSynchronously() {
panicCancelCount += 1
}
func resolveFinish(with url: URL?) {
let continuation = finishContinuation
finishContinuation = nil
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
}
#expect(viewModel.state == .idle)
}
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.panicWipe()
#expect(session.panicCancelCount == 1)
#expect(viewModel.state == .idle)
#expect(!viewModel.isLiveStreaming)
}
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
let session = GatedVoiceCaptureSession()
let viewModel = VoiceRecordingViewModel()
viewModel.sessionProvider = { session }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
try Data([0x01]).write(to: url)
var delivered = false
viewModel.start(shouldShow: true)
await waitUntil { self.isRecording(viewModel.state) }
viewModel.finish { _ in delivered = true }
await waitUntil { session.finishStarted }
viewModel.panicWipe()
session.resolveFinish(with: url)
await waitUntil {
!FileManager.default.fileExists(atPath: url.path)
}
#expect(!delivered)
#expect(viewModel.state == .idle)
}
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
let capture = StubPTTCapture(stopResult: (nil, 0))
var sentPackets: [Data] = []
let session = PTTLiveVoiceSession(
sendPacket: { sentPackets.append($0) },
capture: capture
)
session.panicCancelSynchronously()
#expect(capture.cancelCount == 1)
#expect(sentPackets.isEmpty)
}
}
+29
View File
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
#expect(FileManager.default.fileExists(atPath: secondURL.path))
}
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let rawSession = VoiceRecorderTestSession()
let coordinator = AudioSessionCoordinator(session: rawSession)
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
let voiceRecorder = VoiceRecorder(
sessionCoordinator: coordinator,
recorderFactory: factory,
permissionGranted: { true },
paddingInterval: 0,
outputDirectory: directory
)
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
try await capture.start()
let url = try #require(factory.urls.first)
let recorder = try #require(factory.recorders.first)
capture.panicCancelSynchronously()
#expect(recorder.stopCallCount == 1)
#expect(!recorder.isRecording)
#expect(!FileManager.default.fileExists(atPath: url.path))
await coordinator.drain()
#expect(rawSession.activationCalls == [true, false])
}
private func verifyFailedStart(
firstPlan: TestVoiceAudioRecorderFactory.Plan,
expectedPrepareCalls: Int,
+2 -2
View File
@@ -48,7 +48,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
@@ -88,7 +88,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu
## Panic Wipe Coverage
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.
## Release Review Checklist