mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:25:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15524b1ae6 | ||
|
|
e5c924bf10 |
@@ -94,6 +94,24 @@ jobs:
|
|||||||
kill "$watchdog_pid" 2>/dev/null || true
|
kill "$watchdog_pid" 2>/dev/null || true
|
||||||
exit "$status"
|
exit "$status"
|
||||||
|
|
||||||
|
# Read coverage before the serial benchmark command below rebuilds the
|
||||||
|
# test binary without instrumentation. Reporting against that newer
|
||||||
|
# binary makes llvm-cov reject the profile as out of date.
|
||||||
|
# Informational only: there is deliberately no percentage threshold, but
|
||||||
|
# a broken/missing report is a CI configuration error and must be visible.
|
||||||
|
- name: Coverage summary
|
||||||
|
run: |
|
||||||
|
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||||
|
PROF="$BIN_PATH/codecov/default.profdata"
|
||||||
|
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||||
|
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||||
|
if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
|
||||||
|
echo "::error::Coverage profile or test binary is missing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||||
|
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
|
||||||
|
|
||||||
# Benchmarks run serially on an otherwise idle runner for stable
|
# Benchmarks run serially on an otherwise idle runner for stable
|
||||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||||
- name: Run performance benchmarks (serial)
|
- name: Run performance benchmarks (serial)
|
||||||
@@ -115,22 +133,6 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||||
|
|
||||||
# Informational only: surfaces per-file and total line coverage in the
|
|
||||||
# job log so coverage trends are visible on every PR. No thresholds —
|
|
||||||
# this must never be the reason a build goes red.
|
|
||||||
- name: Coverage summary
|
|
||||||
run: |
|
|
||||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
|
||||||
PROF="$BIN_PATH/codecov/default.profdata"
|
|
||||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
|
||||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
|
||||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
|
||||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
|
||||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
|
||||||
else
|
|
||||||
echo "No coverage data found; skipping summary."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# SPM tests do not link the shipping app targets. This job covers the
|
# SPM tests do not link the shipping app targets. This job covers the
|
||||||
# iOS-conditional paths and both universal Release link configurations.
|
# iOS-conditional paths and both universal Release link configurations.
|
||||||
ios-build:
|
ios-build:
|
||||||
@@ -169,6 +171,52 @@ jobs:
|
|||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
|
||||||
|
# conditional tests. Build the shared iOS test target and run it on the first
|
||||||
|
# available iPhone simulator from the runner image instead of hard-coding a
|
||||||
|
# model that changes when GitHub updates Xcode. The suite intentionally runs
|
||||||
|
# in one test runner: a number of integration tests exercise process-global
|
||||||
|
# stores and notification centers, so overlapping workers can corrupt each
|
||||||
|
# other's fixtures and turn sub-second tests into multi-minute timeouts.
|
||||||
|
ios-tests:
|
||||||
|
name: Run iOS simulator tests
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Select available iPhone simulator
|
||||||
|
id: destination
|
||||||
|
run: |
|
||||||
|
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
|
||||||
|
destination_id=$(awk -F'id:' '
|
||||||
|
/platform:iOS Simulator/ && /name:iPhone/ && !found {
|
||||||
|
value=$2
|
||||||
|
sub(/,.*/, "", value)
|
||||||
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||||
|
print value
|
||||||
|
found=1
|
||||||
|
}
|
||||||
|
' <<< "$destinations")
|
||||||
|
if [ -z "$destination_id" ]; then
|
||||||
|
echo "::error::No available iPhone simulator destination found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Run iOS tests
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild -project bitchat.xcodeproj \
|
||||||
|
-scheme "bitchat (iOS)" \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
|
||||||
|
-parallel-testing-enabled NO \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
test
|
||||||
|
|
||||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||||
# it can never break the documented xcodebuild path or block a merge.
|
# it can never break the documented xcodebuild path or block a merge.
|
||||||
|
|||||||
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
|||||||
|
|
||||||
1. **Identity and cryptographic keys**
|
1. **Identity and cryptographic keys**
|
||||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||||
- 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.
|
- 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, 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.
|
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
||||||
|
|
||||||
2. **Nickname, preferences, and relationships**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
- 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
|
## Your Controls
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **No account:** The project operates no account record for you to request or export.
|
||||||
|
|||||||
Generated
-1
@@ -337,7 +337,6 @@
|
|||||||
es,
|
es,
|
||||||
ar,
|
ar,
|
||||||
de,
|
de,
|
||||||
fa,
|
|
||||||
fr,
|
fr,
|
||||||
he,
|
he,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ final class AppChromeModel: ObservableObject {
|
|||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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.
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
@@ -100,12 +97,7 @@ final class AppChromeModel: ObservableObject {
|
|||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
|
||||||
prepareForPanic = preparation
|
|
||||||
}
|
|
||||||
|
|
||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
prepareForPanic?()
|
|
||||||
chatViewModel.panicClearAllData()
|
chatViewModel.panicClearAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,15 +107,12 @@ final class AppRuntime: ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if chatViewModel.networkActivationAllowed {
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
NotificationDelegate.shared.runtime = self
|
NotificationDelegate.shared.runtime = self
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard chatViewModel.networkActivationAllowed else { return }
|
|
||||||
guard !started else {
|
guard !started else {
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
return
|
return
|
||||||
@@ -154,14 +151,12 @@ final class AppRuntime: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleDidBecomeActiveNotification() {
|
func handleDidBecomeActiveNotification() {
|
||||||
guard chatViewModel.networkActivationAllowed else { return }
|
|
||||||
chatViewModel.handleDidBecomeActive()
|
chatViewModel.handleDidBecomeActive()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
func handleMacDidBecomeActiveNotification() {
|
func handleMacDidBecomeActiveNotification() {
|
||||||
guard chatViewModel.networkActivationAllowed else { return }
|
|
||||||
record(.scenePhaseChanged(.active))
|
record(.scenePhaseChanged(.active))
|
||||||
chatViewModel.handleDidBecomeActive()
|
chatViewModel.handleDidBecomeActive()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
@@ -180,7 +175,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
didEnterBackground = true
|
didEnterBackground = true
|
||||||
|
|
||||||
case .active:
|
case .active:
|
||||||
guard chatViewModel.networkActivationAllowed else { return }
|
|
||||||
record(.scenePhaseChanged(.active))
|
record(.scenePhaseChanged(.active))
|
||||||
chatViewModel.meshService.startServices()
|
chatViewModel.meshService.startServices()
|
||||||
TorManager.shared.setAppForeground(true)
|
TorManager.shared.setAppForeground(true)
|
||||||
@@ -228,7 +222,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
userInfo: [AnyHashable: Any]
|
userInfo: [AnyHashable: Any]
|
||||||
) {
|
) {
|
||||||
guard chatViewModel.networkActivationAllowed else { return }
|
|
||||||
if actionIdentifier == NotificationService.waveActionID {
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
chatViewModel.sendMeshWave()
|
chatViewModel.sendMeshWave()
|
||||||
return
|
return
|
||||||
@@ -280,8 +273,6 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard self?.chatViewModel.networkActivationAllowed == true
|
|
||||||
else { return }
|
|
||||||
self?.record(.torLifecycleChanged(.willRestart))
|
self?.record(.torLifecycleChanged(.willRestart))
|
||||||
self?.chatViewModel.handleTorWillRestart()
|
self?.chatViewModel.handleTorWillRestart()
|
||||||
}
|
}
|
||||||
@@ -290,8 +281,6 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard self?.chatViewModel.networkActivationAllowed == true
|
|
||||||
else { return }
|
|
||||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||||
self?.chatViewModel.handleTorDidBecomeReady()
|
self?.chatViewModel.handleTorDidBecomeReady()
|
||||||
}
|
}
|
||||||
@@ -300,8 +289,6 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard self?.chatViewModel.networkActivationAllowed == true
|
|
||||||
else { return }
|
|
||||||
self?.record(.torLifecycleChanged(.willStart))
|
self?.record(.torLifecycleChanged(.willStart))
|
||||||
self?.chatViewModel.handleTorWillStart()
|
self?.chatViewModel.handleTorWillStart()
|
||||||
}
|
}
|
||||||
@@ -310,8 +297,6 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] notification in
|
.sink { [weak self] notification in
|
||||||
guard self?.chatViewModel.networkActivationAllowed == true
|
|
||||||
else { return }
|
|
||||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||||
}
|
}
|
||||||
@@ -328,7 +313,6 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
guard chatViewModel.networkActivationAllowed else { return }
|
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||||
let clearSharedContent = {
|
let clearSharedContent = {
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
@@ -375,9 +359,7 @@ private extension AppRuntime {
|
|||||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
lastNostrRelayConnectedState = isConnected
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
guard chatViewModel.networkActivationAllowed,
|
guard started, becameConnected else { return }
|
||||||
started,
|
|
||||||
becameConnected else { return }
|
|
||||||
|
|
||||||
let isInitialConnection = !didHandleInitialNostrConnection
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
didHandleInitialNostrConnection = true
|
didHandleInitialNostrConnection = true
|
||||||
|
|||||||
@@ -7,146 +7,45 @@ final class LocationPresenceStore: ObservableObject {
|
|||||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
@Published private(set) var teleportedGeo: Set<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?) {
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
let normalized = geohash?.lowercased()
|
currentGeohash = 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) {
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
guard geoNicknameCapacity > 0 else {
|
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||||
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]) {
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
guard geoNicknameCapacity > 0 else {
|
geoNicknames = Dictionary(
|
||||||
clearGeoNicknames()
|
uniqueKeysWithValues: nicknames.map { key, value in
|
||||||
return
|
(key.lowercased(), value)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
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() {
|
func clearGeoNicknames() {
|
||||||
geoNicknames.removeAll()
|
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) {
|
func markTeleported(_ pubkeyHex: String) {
|
||||||
guard teleportedGeoCapacity > 0 else {
|
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||||
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) {
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
let key = pubkeyHex.lowercased()
|
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||||
teleportedGeo.remove(key)
|
|
||||||
teleportedGeoOrder.removeAll { $0 == key }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
guard teleportedGeoCapacity > 0 else {
|
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||||
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() {
|
func clearTeleportedGeo() {
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
teleportedGeoOrder.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
currentGeohash = nil
|
currentGeohash = nil
|
||||||
geoNicknames.removeAll()
|
geoNicknames.removeAll()
|
||||||
geoNicknameOrder.removeAll()
|
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
teleportedGeoOrder.removeAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ protocol VoiceCaptureSession: AnyObject {
|
|||||||
/// nothing valid was captured.
|
/// nothing valid was captured.
|
||||||
func finish() async -> URL?
|
func finish() async -> URL?
|
||||||
func cancel() async
|
func cancel() async
|
||||||
/// Stops capture and suppresses every later send before returning.
|
|
||||||
func panicCancelSynchronously()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
@@ -57,10 +55,6 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
|||||||
func cancel() async {
|
func cancel() async {
|
||||||
await recorder.cancelRecording(owner: owner)
|
await recorder.cancelRecording(owner: owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
func panicCancelSynchronously() {
|
|
||||||
recorder.panicCancelSynchronously(owner: owner)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Testable surface of the live capture engine. Production uses
|
/// Testable surface of the live capture engine. Production uses
|
||||||
@@ -222,13 +216,6 @@ 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) {
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
sendPacket(packet.encode())
|
sendPacket(packet.encode())
|
||||||
|
|||||||
@@ -246,21 +246,6 @@ actor VoiceRecorder {
|
|||||||
currentURL = nil
|
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 audio session was interrupted (call, Siri) or reconfigured: stop
|
||||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||||
/// `stopRecording()` still returns the partial note.
|
/// `stopRecording()` still returns the partial note.
|
||||||
|
|||||||
+1
-3099
File diff suppressed because it is too large
Load Diff
@@ -66,10 +66,7 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Only initiator writes the first message
|
// Only initiator writes the first message
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
guard let handshake = handshakeState else {
|
let message = try handshakeState!.writeMessage()
|
||||||
throw NoiseSessionError.invalidState
|
|
||||||
}
|
|
||||||
let message = try handshake.writeMessage()
|
|
||||||
sentHandshakeMessages.append(message)
|
sentHandshakeMessages.append(message)
|
||||||
return message
|
return message
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -701,10 +701,6 @@ struct NostrEvent: Codable {
|
|||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
guard Self.isWithinInboundTagLimits(tags) else {
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
self.id = dict["id"] as? String ?? ""
|
self.id = dict["id"] as? String ?? ""
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.created_at = createdAt
|
self.created_at = createdAt
|
||||||
@@ -714,21 +710,6 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
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 {
|
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
|
|||||||
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
|
|||||||
case notice(String)
|
case notice(String)
|
||||||
|
|
||||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
guard let data = message.dataWithinInboundLimit,
|
guard let data = message.data,
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
array.count >= 2,
|
array.count >= 2,
|
||||||
let type = array[0] as? String else {
|
let type = array[0] as? String else {
|
||||||
@@ -1525,19 +1525,11 @@ private enum ParsedInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension URLSessionWebSocketTask.Message {
|
private extension URLSessionWebSocketTask.Message {
|
||||||
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
var data: Data? {
|
||||||
/// where we can (string length), and always before JSON parse.
|
|
||||||
var dataWithinInboundLimit: Data? {
|
|
||||||
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
|
||||||
switch self {
|
switch self {
|
||||||
case .string(let text):
|
case .string(let text): text.data(using: .utf8)
|
||||||
guard text.utf8.count <= maxBytes else { return nil }
|
case .data(let data): data
|
||||||
return text.data(using: .utf8)
|
@unknown default: nil
|
||||||
case .data(let data):
|
|
||||||
guard data.count <= maxBytes else { return nil }
|
|
||||||
return data
|
|
||||||
@unknown default:
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,124 +2,8 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
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 {
|
struct BLEIncomingFileStore {
|
||||||
enum PanicRecoveryError: Error {
|
|
||||||
case externalMarkerCommitFailed
|
|
||||||
case markerWriteFailed(Error)
|
|
||||||
case markerWriteAndMediaWipeFailed(
|
|
||||||
markerError: Error,
|
|
||||||
mediaError: Error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
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
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
@@ -133,96 +17,11 @@ struct BLEIncomingFileStore {
|
|||||||
let fileManager: FileManager
|
let fileManager: FileManager
|
||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
|
||||||
|
|
||||||
init(
|
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.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.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
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
|
/// Resolves (and creates) an incoming-media directory for callers that
|
||||||
@@ -314,39 +113,15 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
private func filesDirectory() throws -> URL {
|
||||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return filesDir
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rootDirectory() throws -> URL {
|
|
||||||
try baseDirectory ?? fileManager.url(
|
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
in: .userDomainMask,
|
in: .userDomainMask,
|
||||||
appropriateFor: nil,
|
appropriateFor: nil,
|
||||||
create: true
|
create: true
|
||||||
)
|
)
|
||||||
}
|
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
private func panicRecoveryPendingMarkerURL() throws -> URL {
|
return filesDir
|
||||||
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 {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ final class BLEService: NSObject {
|
|||||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||||
// packets between in-process service instances.
|
// packets between in-process service instances.
|
||||||
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
||||||
/// May block a synthetic CoreBluetooth receive callback immediately
|
|
||||||
/// before it hands a packet to `messageQueue`.
|
|
||||||
var _test_beforeReceivePacketHandoff: (() -> Void)?
|
|
||||||
var _test_onReceivePacketHandoff: (() -> Void)?
|
|
||||||
#endif
|
#endif
|
||||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||||
private let meshTopology = MeshTopologyTracker()
|
private let meshTopology = MeshTopologyTracker()
|
||||||
@@ -123,7 +119,6 @@ final class BLEService: NSObject {
|
|||||||
private struct PendingMeshPing {
|
private struct PendingMeshPing {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let sentAt: Date
|
let sentAt: Date
|
||||||
let lifecycleGeneration: UInt64
|
|
||||||
let completion: @MainActor (MeshPingResult?) -> Void
|
let completion: @MainActor (MeshPingResult?) -> Void
|
||||||
let timeout: DispatchWorkItem
|
let timeout: DispatchWorkItem
|
||||||
}
|
}
|
||||||
@@ -160,10 +155,6 @@ final class BLEService: NSObject {
|
|||||||
private var centralManager: CBCentralManager?
|
private var centralManager: CBCentralManager?
|
||||||
private var peripheralManager: CBPeripheralManager?
|
private var peripheralManager: CBPeripheralManager?
|
||||||
private var characteristic: CBMutableCharacteristic?
|
private var characteristic: CBMutableCharacteristic?
|
||||||
private let shouldInitializeBluetoothManagers: Bool
|
|
||||||
private let panicLifecycleLock = NSLock()
|
|
||||||
private var _isPanicSuspended: Bool
|
|
||||||
private var panicLifecycleGeneration: UInt64 = 0
|
|
||||||
|
|
||||||
// MARK: - Identity
|
// MARK: - Identity
|
||||||
|
|
||||||
@@ -284,13 +275,10 @@ final class BLEService: NSObject {
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
initializeBluetoothManagers: Bool = true,
|
initializeBluetoothManagers: Bool = true
|
||||||
startSuspendedForPanicRecovery: Bool = false
|
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
|
|
||||||
self._isPanicSuspended = startSuspendedForPanicRecovery
|
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
@@ -339,90 +327,37 @@ final class BLEService: NSObject {
|
|||||||
// any access from another queue (cross-queue reads use readLinkState).
|
// any access from another queue (cross-queue reads use readLinkState).
|
||||||
linkStateStore.assumeOwnership(of: bleQueue)
|
linkStateStore.assumeOwnership(of: bleQueue)
|
||||||
|
|
||||||
if !startSuspendedForPanicRecovery {
|
if initializeBluetoothManagers {
|
||||||
initializeBluetoothManagersIfNeeded()
|
// Initialize BLE on background queue to prevent main thread blocking.
|
||||||
|
#if os(iOS)
|
||||||
|
let centralOptions: [String: Any] = [
|
||||||
|
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||||
|
]
|
||||||
|
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||||
|
|
||||||
|
let peripheralOptions: [String: Any] = [
|
||||||
|
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||||
|
]
|
||||||
|
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
|
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||||
// determinism). Only run it when real Bluetooth managers exist.
|
// determinism). Only run it when real Bluetooth managers exist.
|
||||||
meshBackgroundEnabled = initializeBluetoothManagers
|
meshBackgroundEnabled = initializeBluetoothManagers
|
||||||
if !startSuspendedForPanicRecovery {
|
startMaintenanceTimer()
|
||||||
startMaintenanceTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
|
|
||||||
// Initialize gossip sync manager
|
// Initialize gossip sync manager
|
||||||
if !startSuspendedForPanicRecovery {
|
restartGossipManager()
|
||||||
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
|
|
||||||
]
|
|
||||||
centralManager = CBCentralManager(
|
|
||||||
delegate: self,
|
|
||||||
queue: bleQueue,
|
|
||||||
options: centralOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
let peripheralOptions: [String: Any] = [
|
|
||||||
CBPeripheralManagerOptionRestoreIdentifierKey:
|
|
||||||
BLEService.peripheralRestorationID
|
|
||||||
]
|
|
||||||
peripheralManager = CBPeripheralManager(
|
|
||||||
delegate: self,
|
|
||||||
queue: bleQueue,
|
|
||||||
options: peripheralOptions
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
|
||||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartGossipManager() {
|
private func restartGossipManager() {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
// Stop existing
|
// Stop existing
|
||||||
gossipSyncManager?.stop()
|
gossipSyncManager?.stop()
|
||||||
|
|
||||||
@@ -481,39 +416,7 @@ final class BLEService: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close radio admission before application state starts disappearing.
|
func resetIdentityForPanic(currentNickname: String) {
|
||||||
/// 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
|
|
||||||
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
|
||||||
// callback may already have passed its initial suspension check; the
|
|
||||||
// bleQueue drain forces its final messageQueue handoff to happen
|
|
||||||
// before the receive barrier below.
|
|
||||||
stopServicesImmediatelyForPanic()
|
|
||||||
// Drain every receive/send submitted by callbacks that finished ahead
|
|
||||||
// of the radio stop. Later callbacks observe the closed lifecycle, and
|
|
||||||
// generation-bound handoffs that raced this barrier reject themselves.
|
|
||||||
messageQueue.sync(flags: .barrier) {}
|
|
||||||
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) {
|
messageQueue.sync(flags: .barrier) {
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
}
|
}
|
||||||
@@ -557,19 +460,16 @@ final class BLEService: NSObject {
|
|||||||
configureNoiseServiceCallbacks(for: newNoise)
|
configureNoiseServiceCallbacks(for: newNoise)
|
||||||
refreshPeerIdentity()
|
refreshPeerIdentity()
|
||||||
}
|
}
|
||||||
// Keep the transport silent until the application-level transaction
|
restartGossipManager()
|
||||||
// has also removed its media and committed both recovery markers.
|
|
||||||
myNickname = currentNickname
|
setNickname(currentNickname)
|
||||||
|
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.selfBroadcastTracker.removeAll()
|
self?.selfBroadcastTracker.removeAll()
|
||||||
}
|
}
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
if restartServices {
|
startServices()
|
||||||
restartGossipManager()
|
|
||||||
startServices()
|
|
||||||
sendAnnounce(forceSend: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure this runs on message queue to avoid main thread blocking
|
// Ensure this runs on message queue to avoid main thread blocking
|
||||||
@@ -581,7 +481,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
|
|
||||||
guard content.count <= maxMessageLength else {
|
guard content.count <= maxMessageLength else {
|
||||||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
||||||
@@ -663,9 +562,7 @@ final class BLEService: NSObject {
|
|||||||
/// `startServices()` — the latter matters after a panic reset, where
|
/// `startServices()` — the latter matters after a panic reset, where
|
||||||
/// `stopServices()` cancels and nils the timer.
|
/// `stopServices()` cancels and nils the timer.
|
||||||
private func startMaintenanceTimer() {
|
private func startMaintenanceTimer() {
|
||||||
guard !isPanicSuspended,
|
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
||||||
meshBackgroundEnabled,
|
|
||||||
maintenanceTimer == nil else { return }
|
|
||||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||||
repeating: TransportConfig.bleMaintenanceInterval,
|
repeating: TransportConfig.bleMaintenanceInterval,
|
||||||
@@ -678,12 +575,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
guard let lifecycleGeneration =
|
|
||||||
capturePanicLifecycleGeneration() else { return }
|
|
||||||
initializeBluetoothManagersIfNeeded()
|
|
||||||
if gossipSyncManager == nil {
|
|
||||||
restartGossipManager()
|
|
||||||
}
|
|
||||||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||||
// and cache cleanup would never resume until app restart.
|
// and cache cleanup would never resume until app restart.
|
||||||
@@ -700,11 +591,7 @@ final class BLEService: NSObject {
|
|||||||
// Send initial announce after services are ready
|
// Send initial announce after services are ready
|
||||||
// Use longer delay to avoid conflicts with other announces
|
// Use longer delay to avoid conflicts with other announces
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||||
guard let self,
|
self?.sendAnnounce(forceSend: true)
|
||||||
self.isCurrentPanicLifecycleGeneration(
|
|
||||||
lifecycleGeneration
|
|
||||||
) else { return }
|
|
||||||
self.sendAnnounce(forceSend: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,61 +660,25 @@ 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() {
|
func emergencyDisconnectAll() {
|
||||||
stopServices()
|
stopServices()
|
||||||
clearEmergencySessionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearEmergencySessionState() {
|
|
||||||
// Clear all sessions and peers
|
// Clear all sessions and peers
|
||||||
let cancelled = collectionsQueue.sync(flags: .barrier) {
|
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
|
||||||
let entries = outboundFragmentTransfers.removeAll().map {
|
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
|
||||||
(id: $0.id, items: $0.workItems)
|
|
||||||
}
|
|
||||||
let pingTimeouts = pendingMeshPings.values.map(\.timeout)
|
|
||||||
pendingMeshPings.removeAll()
|
|
||||||
meshPingResponseLimiter = SyncResponseRateLimiter(
|
|
||||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
|
||||||
window: TransportConfig.meshPingInboundWindowSeconds
|
|
||||||
)
|
|
||||||
peerRegistry.removeAll()
|
peerRegistry.removeAll()
|
||||||
fragmentAssemblyBuffer.removeAll()
|
fragmentAssemblyBuffer.removeAll()
|
||||||
sourceRouteFailures = BLESourceRouteFailureCache()
|
sourceRouteFailures = BLESourceRouteFailureCache()
|
||||||
// Also clear pending message queues to avoid stale state across sessions
|
// Also clear pending message queues to avoid stale state across sessions
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
pendingDirectedRelays.removeAll()
|
pendingDirectedRelays.removeAll()
|
||||||
return (transfers: entries, pingTimeouts: pingTimeouts)
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in cancelled.transfers {
|
for entry in cancelledTransfers {
|
||||||
entry.items.forEach { $0.cancel() }
|
entry.items.forEach { $0.cancel() }
|
||||||
TransferProgressManager.shared.cancel(id: entry.id)
|
TransferProgressManager.shared.cancel(id: entry.id)
|
||||||
}
|
}
|
||||||
cancelled.pingTimeouts.forEach { $0.cancel() }
|
|
||||||
|
|
||||||
// Clear processed messages
|
// Clear processed messages
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
@@ -1048,7 +899,6 @@ final class BLEService: NSObject {
|
|||||||
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard !self.isPanicSuspended else { return }
|
|
||||||
guard let payload = filePacket.encode() else {
|
guard let payload = filePacket.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
||||||
return
|
return
|
||||||
@@ -1085,7 +935,6 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard !self.isPanicSuspended else { return }
|
|
||||||
guard let payload = filePacket.encode() else {
|
guard let payload = filePacket.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
return
|
return
|
||||||
@@ -1239,7 +1088,6 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - Packet Broadcasting
|
// MARK: - Packet Broadcasting
|
||||||
|
|
||||||
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
// Apply route if recipient exists (centralized route application)
|
// Apply route if recipient exists (centralized route application)
|
||||||
let packetToSend: BitchatPacket
|
let packetToSend: BitchatPacket
|
||||||
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
||||||
@@ -1321,10 +1169,8 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard !self.isPanicSuspended else { return }
|
|
||||||
let result = self.pendingNotifications.enqueue(
|
let result = self.pendingNotifications.enqueue(
|
||||||
data: data,
|
data: data,
|
||||||
targets: centrals,
|
targets: centrals,
|
||||||
@@ -1425,7 +1271,6 @@ final class BLEService: NSObject {
|
|||||||
requireDirectPeerLink: Bool = false,
|
requireDirectPeerLink: Bool = false,
|
||||||
requireNoiseAuthenticatedPeerLink: Bool = false
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard !isPanicSuspended else { return false }
|
|
||||||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||||
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||||||
if requireNoiseAuthenticatedPeerLink {
|
if requireNoiseAuthenticatedPeerLink {
|
||||||
@@ -1576,7 +1421,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func flushDirectedSpool() {
|
private func flushDirectedSpool() {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||||
let toSend = collectionsQueue.sync(flags: .barrier) {
|
let toSend = collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingDirectedRelays.drainUnexpired(
|
pendingDirectedRelays.drainUnexpired(
|
||||||
@@ -1620,40 +1464,22 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
||||||
guard let generation = capturePanicLifecycleGeneration() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let sync = gossipSyncManager else {
|
guard let sync = gossipSyncManager else {
|
||||||
notifyUI { [weak self] in
|
Task { @MainActor in completion([]) }
|
||||||
guard let self,
|
|
||||||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion([])
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sync.collectPublicMessagePackets { [weak self] packets in
|
sync.collectPublicMessagePackets { [weak self] packets in
|
||||||
guard let self,
|
guard let self = self else {
|
||||||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
Task { @MainActor in completion([]) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Signature verification and registry lookups run on messageQueue
|
// Signature verification and registry lookups run on messageQueue
|
||||||
// like the live receive path.
|
// like the live receive path.
|
||||||
self.messageQueue.async {
|
self.messageQueue.async {
|
||||||
guard self.isCurrentPanicLifecycleGeneration(generation) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let decoded = packets
|
let decoded = packets
|
||||||
.compactMap { self.decodeArchivedPublicMessage($0) }
|
.compactMap { self.decodeArchivedPublicMessage($0) }
|
||||||
.sorted { $0.timestamp < $1.timestamp }
|
.sorted { $0.timestamp < $1.timestamp }
|
||||||
self.notifyUI { [weak self] in
|
Task { @MainActor in completion(decoded) }
|
||||||
guard let self,
|
|
||||||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(decoded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1811,7 +1637,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||||
return
|
return
|
||||||
@@ -2021,13 +1846,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||||
guard !isPanicSuspended else {
|
|
||||||
central.stopScan()
|
|
||||||
restoredPeripherals.forEach {
|
|
||||||
central.cancelPeripheralConnection($0)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||||||
@@ -2083,10 +1901,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
|
|
||||||
switch central.state {
|
switch central.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
guard !isPanicSuspended else {
|
|
||||||
central.stopScan()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Links restored as connected have no characteristic in the new
|
// Links restored as connected have no characteristic in the new
|
||||||
// process; without rediscovery they sit connected-but-unusable
|
// process; without rediscovery they sit connected-but-unusable
|
||||||
// until the peer disconnects. Runs here (not willRestoreState)
|
// until the peer disconnects. Runs here (not willRestoreState)
|
||||||
@@ -2143,8 +1957,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startScanning() {
|
private func startScanning() {
|
||||||
guard !isPanicSuspended,
|
guard let central = centralManager,
|
||||||
let central = centralManager,
|
|
||||||
central.state == .poweredOn,
|
central.state == .poweredOn,
|
||||||
!central.isScanning else { return }
|
!central.isScanning else { return }
|
||||||
|
|
||||||
@@ -2165,7 +1978,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
||||||
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
||||||
@@ -2207,10 +2019,6 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
guard !isPanicSuspended else {
|
|
||||||
central.cancelPeripheralConnection(peripheral)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -2361,9 +2169,7 @@ private extension CBPeripheralState {
|
|||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
private func tryConnectFromQueue() {
|
private func tryConnectFromQueue() {
|
||||||
guard !isPanicSuspended,
|
guard let central = centralManager, central.state == .poweredOn else { return }
|
||||||
let central = centralManager,
|
|
||||||
central.state == .poweredOn else { return }
|
|
||||||
|
|
||||||
let decision = connectionScheduler.nextCandidate(
|
let decision = connectionScheduler.nextCandidate(
|
||||||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||||
@@ -2389,7 +2195,6 @@ extension BLEService {
|
|||||||
using central: CBCentralManager,
|
using central: CBCentralManager,
|
||||||
logPrefix: String
|
logPrefix: String
|
||||||
) {
|
) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
let peripheral = candidate.peripheral
|
let peripheral = candidate.peripheral
|
||||||
let peripheralID = candidate.peripheralID
|
let peripheralID = candidate.peripheralID
|
||||||
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
||||||
@@ -2450,28 +2255,6 @@ private extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only helper to inject packets into the receive pipeline
|
// Test-only helper to inject packets into the receive pipeline
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
/// Queues an event through the same MainActor hop as production receive
|
|
||||||
/// handlers so panic-boundary tests can deterministically invalidate it.
|
|
||||||
func _test_emitTransportEvent(_ event: TransportEvent) {
|
|
||||||
emitTransportEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _test_isPanicIngressOpen: Bool {
|
|
||||||
capturePanicLifecycleGeneration() != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Models a CoreBluetooth delegate callback without requiring a physical
|
|
||||||
/// peripheral. The callback itself runs on `bleQueue`, exactly where the
|
|
||||||
/// panic radio-stop barrier must linearize it.
|
|
||||||
func _test_handlePacketFromBLEQueue(
|
|
||||||
_ packet: BitchatPacket,
|
|
||||||
fromPeerID: PeerID
|
|
||||||
) {
|
|
||||||
bleQueue.async { [weak self] in
|
|
||||||
self?.handleReceivedPacket(packet, from: fromPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||||
if preseedPeer {
|
if preseedPeer {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||||
@@ -2593,7 +2376,6 @@ extension BLEService {
|
|||||||
|
|
||||||
extension BLEService: CBPeripheralDelegate {
|
extension BLEService: CBPeripheralDelegate {
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||||
// Retry service discovery after a delay
|
// Retry service discovery after a delay
|
||||||
@@ -2620,7 +2402,6 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2668,7 +2449,6 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2786,7 +2566,6 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
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)
|
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||||||
@@ -2795,7 +2574,6 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||||
|
|
||||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||||
@@ -2816,7 +2594,6 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
@@ -2840,12 +2617,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
switch peripheral.state {
|
switch peripheral.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
guard !isPanicSuspended else {
|
|
||||||
peripheral.stopAdvertising()
|
|
||||||
peripheral.removeAllServices()
|
|
||||||
characteristic = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Remove all services first to ensure clean state
|
// Remove all services first to ensure clean state
|
||||||
peripheral.removeAllServices()
|
peripheral.removeAllServices()
|
||||||
|
|
||||||
@@ -2906,12 +2677,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
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 restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||||
|
|
||||||
@@ -2938,10 +2703,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||||
guard !isPanicSuspended else {
|
|
||||||
peripheral.stopAdvertising()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2957,7 +2718,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
let centralUUID = central.identifier.uuidString
|
let centralUUID = central.identifier.uuidString
|
||||||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||||||
linkStateStore.addSubscribedCentral(central)
|
linkStateStore.addSubscribedCentral(central)
|
||||||
@@ -2999,7 +2759,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||||
|
|
||||||
// Ensure we're still advertising for other devices to find us
|
// Ensure we're still advertising for other devices to find us
|
||||||
if !isPanicSuspended, peripheral.isAdvertising == false {
|
if peripheral.isAdvertising == false {
|
||||||
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||||||
peripheral.startAdvertising(buildAdvertisementData())
|
peripheral.startAdvertising(buildAdvertisementData())
|
||||||
}
|
}
|
||||||
@@ -3036,7 +2796,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3097,7 +2856,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
for request in requests {
|
for request in requests {
|
||||||
peripheral.respond(to: request, withResult: .success)
|
peripheral.respond(to: request, withResult: .success)
|
||||||
}
|
}
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
|
|
||||||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||||||
// Combine per-central request values by offset before decoding.
|
// Combine per-central request values by offset before decoding.
|
||||||
@@ -3207,18 +2965,8 @@ extension BLEService {
|
|||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||||
private func notifyUI(_ block: @escaping @MainActor () -> Void) {
|
private func notifyUI(_ block: @escaping @MainActor () -> Void) {
|
||||||
// Capture the panic lifecycle before queueing the MainActor hop. A
|
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
||||||
// receive callback can enqueue UI delivery immediately before panic
|
Task { @MainActor in
|
||||||
// clears application state; rechecking here prevents that stale work
|
|
||||||
// from repopulating the wiped conversation store afterward.
|
|
||||||
guard let generation = capturePanicLifecycleGeneration() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self,
|
|
||||||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
block()
|
block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3383,24 +3131,14 @@ extension BLEService {
|
|||||||
/// The completion fires exactly once on the main actor: with RTT/hops
|
/// The completion fires exactly once on the main actor: with RTT/hops
|
||||||
/// when the matching pong returns, or nil after the timeout window.
|
/// when the matching pong returns, or nil after the timeout window.
|
||||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||||
guard let generation = capturePanicLifecycleGeneration() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self,
|
guard let self,
|
||||||
self.isCurrentPanicLifecycleGeneration(generation),
|
|
||||||
let recipientData = peerID.toShort().routingData,
|
let recipientData = peerID.toShort().routingData,
|
||||||
let payload = MeshPingPayload(
|
let payload = MeshPingPayload(
|
||||||
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
||||||
originTTL: self.messageTTL
|
originTTL: self.messageTTL
|
||||||
) else {
|
) else {
|
||||||
self?.notifyUI { [weak self] in
|
Task { @MainActor in completion(nil) }
|
||||||
guard let self,
|
|
||||||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(nil)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let nonce = payload.nonce
|
let nonce = payload.nonce
|
||||||
@@ -3419,21 +3157,12 @@ extension BLEService {
|
|||||||
self.pendingMeshPings.removeValue(forKey: nonce)
|
self.pendingMeshPings.removeValue(forKey: nonce)
|
||||||
}
|
}
|
||||||
guard let expired else { return }
|
guard let expired else { return }
|
||||||
self.notifyUI { [weak self] in
|
Task { @MainActor in expired.completion(nil) }
|
||||||
guard let self,
|
|
||||||
self.isCurrentPanicLifecycleGeneration(
|
|
||||||
expired.lifecycleGeneration
|
|
||||||
) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expired.completion(nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.collectionsQueue.sync(flags: .barrier) {
|
self.collectionsQueue.sync(flags: .barrier) {
|
||||||
self.pendingMeshPings[nonce] = PendingMeshPing(
|
self.pendingMeshPings[nonce] = PendingMeshPing(
|
||||||
peerID: PeerID(hexData: recipientData),
|
peerID: PeerID(hexData: recipientData),
|
||||||
sentAt: Date(),
|
sentAt: Date(),
|
||||||
lifecycleGeneration: generation,
|
|
||||||
completion: completion,
|
completion: completion,
|
||||||
timeout: timeout
|
timeout: timeout
|
||||||
)
|
)
|
||||||
@@ -3500,15 +3229,7 @@ extension BLEService {
|
|||||||
rttMs: max(0, rttMs),
|
rttMs: max(0, rttMs),
|
||||||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||||||
)
|
)
|
||||||
notifyUI { [weak self] in
|
Task { @MainActor in pending.completion(result) }
|
||||||
guard let self,
|
|
||||||
self.isCurrentPanicLifecycleGeneration(
|
|
||||||
pending.lifecycleGeneration
|
|
||||||
) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.completion(result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
||||||
@@ -3992,7 +3713,7 @@ extension BLEService {
|
|||||||
let store = courierStore
|
let store = courierStore
|
||||||
let policy = courierDepositPolicy
|
let policy = courierDepositPolicy
|
||||||
let metrics = sfMetrics
|
let metrics = sfMetrics
|
||||||
notifyUI {
|
Task { @MainActor in
|
||||||
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
||||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -4072,7 +3793,7 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let policy = courierDepositPolicy
|
let policy = courierDepositPolicy
|
||||||
notifyUI {
|
Task { @MainActor in
|
||||||
// Same trust gate as deposits: don't hand mail to a peer who
|
// Same trust gate as deposits: don't hand mail to a peer who
|
||||||
// would reject it from us.
|
// would reject it from us.
|
||||||
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||||||
@@ -4417,7 +4138,6 @@ extension BLEService {
|
|||||||
let uuid = peripheral.identifier.uuidString
|
let uuid = peripheral.identifier.uuidString
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
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 }
|
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
|
// Atomically take all pending items from the queue to avoid race conditions
|
||||||
@@ -4508,10 +4228,7 @@ extension BLEService {
|
|||||||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
||||||
) {
|
) {
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self,
|
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
|
||||||
!self.isPanicSuspended,
|
|
||||||
let central = self.centralManager,
|
|
||||||
central.state == .poweredOn else { return }
|
|
||||||
let budget = TransportConfig.bleMaxCentralLinks
|
let budget = TransportConfig.bleMaxCentralLinks
|
||||||
- slotReserve
|
- slotReserve
|
||||||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
||||||
@@ -4964,24 +4681,8 @@ extension BLEService {
|
|||||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
// Call directly if already on messageQueue, otherwise dispatch
|
// Call directly if already on messageQueue, otherwise dispatch
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
guard let lifecycleGeneration =
|
|
||||||
capturePanicLifecycleGeneration() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#if DEBUG
|
|
||||||
_test_beforeReceivePacketHandoff?()
|
|
||||||
#endif
|
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self,
|
self?.handleReceivedPacket(packet, from: peerID)
|
||||||
self.isCurrentPanicLifecycleGeneration(
|
|
||||||
lifecycleGeneration
|
|
||||||
) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#if DEBUG
|
|
||||||
self._test_onReceivePacketHandoff?()
|
|
||||||
#endif
|
|
||||||
self.handleReceivedPacket(packet, from: peerID)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5825,7 +5526,8 @@ extension BLEService {
|
|||||||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||||||
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
||||||
}
|
}
|
||||||
notifyUI { [weak self] in
|
// Notify UI on MainActor via delegate
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5833,7 +5535,6 @@ extension BLEService {
|
|||||||
// MARK: Consolidated Maintenance
|
// MARK: Consolidated Maintenance
|
||||||
|
|
||||||
private func performMaintenance() {
|
private func performMaintenance() {
|
||||||
guard !isPanicSuspended else { return }
|
|
||||||
maintenanceCounter += 1
|
maintenanceCounter += 1
|
||||||
lastMaintenanceAt = Date()
|
lastMaintenanceAt = Date()
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
private var subscriptions = Set<AnyCancellable>()
|
||||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
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 availableChannelsProvider: () -> [GeohashChannel]
|
||||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||||
@@ -150,25 +147,10 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
/// Start the service (safe to call multiple times)
|
||||||
func start() {
|
func start() {
|
||||||
guard !started else { return }
|
|
||||||
started = true
|
|
||||||
heartbeatGeneration &+= 1
|
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
SecureLogger.info("Presence: service starting...", category: .session)
|
||||||
scheduleNextHeartbeat()
|
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() {
|
private func setupObservers() {
|
||||||
// Monitor location channel changes
|
// Monitor location channel changes
|
||||||
locationChanges
|
locationChanges
|
||||||
@@ -187,26 +169,20 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleLocationChange() {
|
func handleLocationChange() {
|
||||||
guard started else { return }
|
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||||
// to announce presence in the new zone, then reset the loop.
|
// to announce presence in the new zone, then reset the loop.
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
// Small delay to allow location state to settle
|
||||||
let generation = heartbeatGeneration
|
|
||||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self,
|
self?.performHeartbeat()
|
||||||
self.started,
|
|
||||||
self.heartbeatGeneration == generation else { return }
|
|
||||||
self.performHeartbeat()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleConnectivityChange() {
|
func handleConnectivityChange() {
|
||||||
guard started else { return }
|
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||||
// If we were waiting for network, do it now
|
// If we were waiting for network, do it now
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||||
@@ -215,29 +191,18 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scheduleNextHeartbeat() {
|
func scheduleNextHeartbeat() {
|
||||||
guard started else { return }
|
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||||
let generation = heartbeatGeneration
|
|
||||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self,
|
self?.performHeartbeat()
|
||||||
self.started,
|
|
||||||
self.heartbeatGeneration == generation else { return }
|
|
||||||
self.performHeartbeat()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func performHeartbeat() {
|
func performHeartbeat() {
|
||||||
guard started else { return }
|
|
||||||
let generation = heartbeatGeneration
|
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||||
defer {
|
defer { scheduleNextHeartbeat() }
|
||||||
if started, heartbeatGeneration == generation {
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Check preconditions
|
// 1. Check preconditions
|
||||||
guard torIsReady() else {
|
guard torIsReady() else {
|
||||||
@@ -263,27 +228,14 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
// Launch independent task for each channel's delay
|
||||||
let taskID = UUID()
|
Task { @MainActor in
|
||||||
let sleeper = self.sleeper
|
|
||||||
let delay = TimeInterval.random(
|
|
||||||
in: burstMinDelay...burstMaxDelay
|
|
||||||
)
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
let task = Task { @MainActor [weak self] in
|
|
||||||
// Random delay for decorrelation
|
// Random delay for decorrelation
|
||||||
await sleeper(nanoseconds)
|
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
await self.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)
|
self.broadcastPresence(for: channel.geohash)
|
||||||
}
|
}
|
||||||
pendingBroadcastTasks[taskID] = task
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,6 @@ import BitFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
enum KeychainInstallLifecycleAction: Equatable {
|
|
||||||
case markerPresent
|
|
||||||
case bootstrapMarker
|
|
||||||
case clearStaleKeys
|
|
||||||
case retryLater
|
|
||||||
}
|
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
/// Default keychain for components that construct their own rather than
|
/// Default keychain for components that construct their own rather than
|
||||||
/// having one injected. Under test this is an in-memory keychain: the
|
/// having one injected. Under test this is an in-memory keychain: the
|
||||||
@@ -48,80 +41,27 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||||
// device locked (identity-cache saves failed with -25308 throughout
|
// device locked (identity-cache saves failed with -25308 throughout
|
||||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||||
// restoration must be able to read the noise keys before the user
|
// restoration must be able to read the noise keys before the user
|
||||||
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||||
// migrating through device backups onto a second device.
|
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
reconcileInstallLifecycle()
|
|
||||||
migrateAccessibilityIfNeeded()
|
migrateAccessibilityIfNeeded()
|
||||||
#endif
|
#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)
|
#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
|
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||||
/// the right class on their own (saves are delete-then-add), but the
|
/// the right class on their own (saves are delete-then-add), but the
|
||||||
/// long-lived identity keys are written once and would otherwise stay
|
/// long-lived identity keys are written once and would otherwise stay
|
||||||
/// unreadable while the device is locked.
|
/// unreadable while the device is locked.
|
||||||
private func migrateAccessibilityIfNeeded() {
|
private func migrateAccessibilityIfNeeded() {
|
||||||
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||||
|
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
@@ -136,7 +76,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
case errSecSuccess, errSecItemNotFound:
|
case errSecSuccess, errSecItemNotFound:
|
||||||
// Nothing to migrate on a fresh install; both are terminal.
|
// Nothing to migrate on a fresh install; both are terminal.
|
||||||
UserDefaults.standard.set(true, forKey: flag)
|
UserDefaults.standard.set(true, forKey: flag)
|
||||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly (status \(status))", category: .keychain)
|
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||||
default:
|
default:
|
||||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||||
// leave the flag unset so the next launch retries.
|
// leave the flag unset so the next launch retries.
|
||||||
@@ -552,15 +492,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
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
|
return totalDeleted > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,39 +526,18 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||||
let primaryKeyQuery: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
kSecAttrAccount as String: key
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
]
|
]
|
||||||
var addQuery = primaryKeyQuery
|
if let accessible = accessible {
|
||||||
addQuery.merge([
|
query[kSecAttrAccessible as String] = accessible
|
||||||
kSecValueData as String: data,
|
}
|
||||||
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
|
||||||
kSecAttrSynchronizable as String: false
|
|
||||||
]) { _, new in new }
|
|
||||||
|
|
||||||
// Delete by the item's primary key only. Value/accessibility fields
|
SecItemDelete(query as CFDictionary)
|
||||||
// are add attributes, not valid selectors for replacing an existing
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
// 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
|
/// Load data from a custom service
|
||||||
|
|||||||
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content, use plain formatting to avoid expensive
|
// For very long content without special tokens, use plain formatting
|
||||||
// regex/detector work. Cashu presence must not disable this guard.
|
let containsCashu = containsCashuToken(content)
|
||||||
if content.isOversizedForRichFormatting() {
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,19 +154,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.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) {
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
guard enabled != userTorEnabled else { return }
|
guard enabled != userTorEnabled else { return }
|
||||||
userTorEnabled = enabled
|
userTorEnabled = enabled
|
||||||
@@ -180,7 +167,6 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reevaluate() {
|
private func reevaluate() {
|
||||||
guard started else { return }
|
|
||||||
let allowed = effectiveAllowed()
|
let allowed = effectiveAllowed()
|
||||||
let torDesired = allowed && userTorEnabled
|
let torDesired = allowed && userTorEnabled
|
||||||
let statusChanged = allowed != activationAllowed
|
let statusChanged = allowed != activationAllowed
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ protocol NetworkReachabilityMonitoring: AnyObject {
|
|||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||||
/// Begin monitoring. Idempotent.
|
/// Begin monitoring. Idempotent.
|
||||||
func start()
|
func start()
|
||||||
/// Stop monitoring and discard pending debounce work. Idempotent.
|
|
||||||
func stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
/// Pure debounce/decision logic for reachability, split out so it can be
|
||||||
@@ -100,7 +98,6 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
|||||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func start() {}
|
func start() {}
|
||||||
func stop() {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||||
@@ -149,18 +146,6 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
|||||||
#endif
|
#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.
|
/// Feed an observation into the debounce and publish committed changes.
|
||||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||||
func ingest(reachable: Bool) {
|
func ingest(reachable: Bool) {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ enum TransportConfig {
|
|||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
static let meshTimelineCap: Int = 1337
|
static let meshTimelineCap: Int = 1337
|
||||||
static let geoTimelineCap: Int = 1337
|
static let geoTimelineCap: Int = 1337
|
||||||
static let geoNicknameParticipantsCap: Int = 1337
|
|
||||||
static let contentLRUCap: Int = 2000
|
static let contentLRUCap: Int = 2000
|
||||||
static let geoSamplingEventLRUCap: Int = 2000
|
static let geoSamplingEventLRUCap: Int = 2000
|
||||||
|
|
||||||
@@ -82,11 +81,6 @@ enum TransportConfig {
|
|||||||
static let nostrDuplicateEventLogInterval: Int = 50
|
static let nostrDuplicateEventLogInterval: Int = 50
|
||||||
// Sample interval for per-event debug logs on the inbound hot path.
|
// Sample interval for per-event debug logs on the inbound hot path.
|
||||||
static let nostrInboundEventLogInterval: Int = 100
|
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)
|
// Conversation store diagnostics (field observability)
|
||||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||||
@@ -104,12 +98,6 @@ enum TransportConfig {
|
|||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
static let uiContentRateBucketCapacity: Double = 3
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
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
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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,21 +226,6 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
assemblies.values.contains { $0.messageID == message.id }
|
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
|
/// 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
|
/// 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
|
/// name), swap it into the existing live bubble and report `true` so the
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
private var preparationGeneration: UInt64 = 0
|
|
||||||
|
|
||||||
init(context: any ChatMediaTransferContext) {
|
init(context: any ChatMediaTransferContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -99,14 +98,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
let generation = preparationGeneration
|
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
do {
|
do {
|
||||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self else { return }
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
@@ -118,13 +116,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self 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"))
|
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 {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self 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"))
|
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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,15 +132,11 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func processThenSendImage(_ image: UIImage?) {
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
guard let image else { return }
|
guard let image else { return }
|
||||||
let generation = preparationGeneration
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else {
|
guard let self else { return }
|
||||||
try? FileManager.default.removeItem(at: processedURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,15 +147,11 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
func processThenSendImage(from url: URL?) {
|
func processThenSendImage(from url: URL?) {
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
let generation = preparationGeneration
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else {
|
guard let self else { return }
|
||||||
try? FileManager.default.removeItem(at: processedURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -180,7 +170,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
let generation = preparationGeneration
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try ImageUtils.validateImageSource(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
@@ -195,10 +184,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else {
|
guard let self else { return }
|
||||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
@@ -215,13 +201,13 @@ final class ChatMediaTransferCoordinator {
|
|||||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self else { return }
|
||||||
self.context.addSystemMessage("Image is too large to send.")
|
self.context.addSystemMessage("Image is too large to send.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self, self.preparationGeneration == generation else { return }
|
guard let self else { return }
|
||||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,19 +341,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
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 {
|
private extension ChatMediaTransferCoordinator {
|
||||||
|
|||||||
@@ -71,8 +71,12 @@ final class ChatMessageFormatter {
|
|||||||
let content = message.content
|
let content = message.content
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
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.isOversizedForRichFormatting() {
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
plainStyle.foregroundColor = baseColor
|
plainStyle.foregroundColor = baseColor
|
||||||
plainStyle.font = isSelf
|
plainStyle.font = isSelf
|
||||||
|
|||||||
@@ -89,26 +89,6 @@ import UIKit
|
|||||||
#endif
|
#endif
|
||||||
import UniformTypeIdentifiers
|
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.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
@@ -162,8 +142,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var currentColorScheme: ColorScheme = .light
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
@Published var currentTheme: AppTheme = .matrix
|
@Published var currentTheme: AppTheme = .matrix
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
@Published private(set) var panicRecoveryBlocked = false
|
|
||||||
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||||
@@ -173,7 +151,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
// Update mesh service nickname if it's initialized
|
||||||
if !isPanicResetting, !meshService.myPeerID.isEmpty {
|
if !meshService.myPeerID.isEmpty {
|
||||||
meshService.setNickname(nickname)
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,10 +177,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
||||||
context: self,
|
|
||||||
sweepsOnInit: !TestEnvironment.isRunningTests
|
|
||||||
)
|
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
@@ -317,9 +292,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
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.
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
let groupStore: GroupStore
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
@@ -797,34 +769,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared
|
||||||
) {
|
) {
|
||||||
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
|
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||||
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
|
meshService.sfMetrics = .shared
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
@@ -836,9 +781,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||||
sfMetrics: .shared,
|
sfMetrics: .shared
|
||||||
panicRecoveryOperations: panicRecoveryOperations,
|
|
||||||
panicNetworkLifecycle: .live
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,10 +799,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
readReceiptsDefaults: UserDefaults? = nil,
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
outboxStore: MessageOutboxStore? = 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 conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -874,9 +814,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.panicRecoveryOperations = panicRecoveryOperations
|
|
||||||
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
|
|
||||||
self.panicNetworkLifecycle = panicNetworkLifecycle
|
|
||||||
self.groupStore = GroupStore(keychain: keychain)
|
self.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
@@ -912,31 +849,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
let recoveryRequired: Bool
|
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||||
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
|
// MARK: - Deinitialization
|
||||||
@@ -1240,33 +1153,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// PANIC: Emergency data clearing for activist safety
|
// PANIC: Emergency data clearing for activist safety
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
func panicClearAllData() {
|
||||||
func panicClearAllData(restartServices: Bool = true) -> Bool {
|
// Messages are processed immediately - nothing to flush
|
||||||
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
|
// Clear all messages (public timelines and private chats live in the
|
||||||
// single-writer ConversationStore; the derived `messages` view and
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
@@ -1288,14 +1176,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Reset nickname to anonymous
|
// Reset nickname to anonymous
|
||||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||||
userDefaults.set(nickname, forKey: nicknameKey)
|
saveNickname()
|
||||||
|
|
||||||
// Clear favorites and peer mappings
|
// Clear favorites and peer mappings
|
||||||
// Clear through SecureIdentityStateManager instead of directly
|
// Clear through SecureIdentityStateManager instead of directly
|
||||||
identityManager.clearAllIdentityData()
|
identityManager.clearAllIdentityData()
|
||||||
peerIdentityStore.clearAll()
|
peerIdentityStore.clearAll()
|
||||||
locationPresenceStore.reset()
|
locationPresenceStore.reset()
|
||||||
publicRateLimiter.reset()
|
|
||||||
|
|
||||||
// Clear persistent favorites from keychain
|
// Clear persistent favorites from keychain
|
||||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
@@ -1360,68 +1247,78 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
idBridge.clearAllAssociations()
|
idBridge.clearAllAssociations()
|
||||||
|
|
||||||
// Replace the BLE identity while keeping the radio stopped. It may
|
// Disconnect from all peers and clear persistent identity
|
||||||
// reopen only after the durable panic transaction commits.
|
// This will force creation of a new identity (new fingerprint) on next launch
|
||||||
|
meshService.emergencyDisconnectAll()
|
||||||
if let bleService = meshService as? BLEService {
|
if let bleService = meshService as? BLEService {
|
||||||
bleService.resetIdentityForPanic(
|
bleService.resetIdentityForPanic(currentNickname: nickname)
|
||||||
currentNickname: nickname,
|
|
||||||
restartServices: false
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
meshService.setNickname(nickname)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wipe must finish before this security action returns. A detached
|
// No need to force UserDefaults synchronization
|
||||||
// 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
|
|
||||||
|
|
||||||
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
// Reinitialize Nostr with new identity
|
||||||
// the host user's real cache tree just as the default media wipe does.
|
// This will generate new Nostr keys derived from new Noise keys.
|
||||||
#if os(iOS)
|
// 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).
|
||||||
if !TestEnvironment.isRunningTests {
|
if !TestEnvironment.isRunningTests {
|
||||||
Self.clearAppSwitcherSnapshots()
|
Task { @MainActor in
|
||||||
}
|
// Small delay to ensure cleanup completes
|
||||||
#endif
|
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||||
|
|
||||||
guard panicCompleted else { return false }
|
// Reinitialize Nostr relay manager with new identity. Reuse the
|
||||||
|
// shared singleton — every other component (NostrTransport, geohash
|
||||||
if let bleService = meshService as? BLEService {
|
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
||||||
// Startup recovery reopens admission but leaves actual service
|
// creating a fresh instance here would split relay state and leave
|
||||||
// start to the bootstrapper immediately after this method.
|
// sends running against a disconnected manager.
|
||||||
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
|
nostrRelayManager = NostrRelayManager.shared
|
||||||
setupNostrMessageHandling()
|
setupNostrMessageHandling()
|
||||||
|
nostrRelayManager?.connect()
|
||||||
}
|
}
|
||||||
panicNetworkLifecycle.restart()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
// 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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
||||||
|
|||||||
@@ -156,17 +156,6 @@ private extension ChatViewModelBootstrapper {
|
|||||||
viewModel?.objectWillChange.send()
|
viewModel?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.cancellables)
|
.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() {
|
func loadPersistedViewState() {
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ struct MessageRateLimiter {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
|
|
||||||
now.timeIntervalSince(lastRefill) >= idleTTL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
private var senderBuckets: [String: TokenBucket] = [:]
|
||||||
@@ -39,26 +35,17 @@ struct MessageRateLimiter {
|
|||||||
private let senderRefill: Double
|
private let senderRefill: Double
|
||||||
private let contentCapacity: Double
|
private let contentCapacity: Double
|
||||||
private let contentRefill: Double
|
private let contentRefill: Double
|
||||||
private let maxSenderBuckets: Int
|
|
||||||
private let maxContentBuckets: Int
|
|
||||||
private let bucketIdleTTL: TimeInterval
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
senderCapacity: Double,
|
senderCapacity: Double,
|
||||||
senderRefillPerSec: Double,
|
senderRefillPerSec: Double,
|
||||||
contentCapacity: Double,
|
contentCapacity: Double,
|
||||||
contentRefillPerSec: Double,
|
contentRefillPerSec: Double
|
||||||
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
|
|
||||||
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
|
|
||||||
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
|
|
||||||
) {
|
) {
|
||||||
self.senderCapacity = senderCapacity
|
self.senderCapacity = senderCapacity
|
||||||
self.senderRefill = senderRefillPerSec
|
self.senderRefill = senderRefillPerSec
|
||||||
self.contentCapacity = contentCapacity
|
self.contentCapacity = contentCapacity
|
||||||
self.contentRefill = contentRefillPerSec
|
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
|
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||||
@@ -71,83 +58,25 @@ struct MessageRateLimiter {
|
|||||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||||
senderAllowed = true
|
senderAllowed = true
|
||||||
} else {
|
} else {
|
||||||
var senderBucket = Self.bucket(
|
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||||
for: senderKey,
|
|
||||||
in: &senderBuckets,
|
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
|
tokens: senderCapacity,
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
maxBuckets: maxSenderBuckets,
|
lastRefill: now
|
||||||
idleTTL: bucketIdleTTL,
|
|
||||||
now: now
|
|
||||||
)
|
)
|
||||||
senderAllowed = senderBucket.allow(now: now)
|
senderAllowed = senderBucket.allow(now: now)
|
||||||
senderBuckets[senderKey] = senderBucket
|
senderBuckets[senderKey] = senderBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rejected senders must not mint attacker-keyed content entries.
|
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||||
guard senderAllowed else { return false }
|
|
||||||
|
|
||||||
var contentBucket = Self.bucket(
|
|
||||||
for: contentKey,
|
|
||||||
in: &contentBuckets,
|
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
|
tokens: contentCapacity,
|
||||||
refillPerSec: contentRefill,
|
refillPerSec: contentRefill,
|
||||||
maxBuckets: maxContentBuckets,
|
lastRefill: now
|
||||||
idleTTL: bucketIdleTTL,
|
|
||||||
now: now
|
|
||||||
)
|
)
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
contentBuckets[contentKey] = contentBucket
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
return contentAllowed
|
return senderAllowed && 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,10 +196,7 @@ final class NostrInboundPipeline {
|
|||||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
geoEventLogCount += 1
|
geoEventLogCount += 1
|
||||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
SecureLogger.debug(
|
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||||
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
|
|||||||
@@ -188,25 +188,8 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
let finalDuration = Date().timeIntervalSince(startDate)
|
let finalDuration = Date().timeIntervalSince(startDate)
|
||||||
if let url = await session.finish() {
|
if let url = await session.finish(),
|
||||||
// Panic and a newer hold both invalidate this completion.
|
isValidRecording(at: url, duration: finalDuration) {
|
||||||
// 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)
|
completion(url)
|
||||||
} else {
|
} else {
|
||||||
guard generation == holdGeneration, state == .idle else { return }
|
guard generation == holdGeneration, state == .idle else { return }
|
||||||
@@ -223,17 +206,6 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
finish(completion: nil)
|
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 {
|
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
let fileSize = attributes[.size] as? NSNumber,
|
let fileSize = attributes[.size] as? NSNumber,
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ struct AppInfoView: View {
|
|||||||
/// introduction), and afterwards the sheet reopens wherever it was left.
|
/// introduction), and afterwards the sheet reopens wherever it was left.
|
||||||
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
||||||
@State private var showPanicConfirmation = false
|
@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 {
|
private enum Pane: String {
|
||||||
case settings
|
case settings
|
||||||
@@ -59,11 +55,6 @@ 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 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 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 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 {
|
static func bridgeCell(_ cell: String) -> String {
|
||||||
@@ -322,52 +313,6 @@ 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.
|
// Voice — same card + IRC pill as every other toggle setting.
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(Strings.Voice.title)
|
SectionHeader(Strings.Voice.title)
|
||||||
@@ -513,24 +458,6 @@ struct AppInfoView: View {
|
|||||||
.padding()
|
.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> {
|
private var bridgeToggleBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { bridgeService.isEnabled },
|
get: { bridgeService.isEnabled },
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct TextMessageView: View {
|
|||||||
// first text line; a fixed top padding left the lock's solid body
|
// first text line; a fixed top padding left the lock's solid body
|
||||||
// hanging below the line's visual center.
|
// hanging below the line's visual center.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
let isLong = message.content.isLongForDisplay()
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
@@ -103,7 +103,7 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if message.content.isLongForDisplay() {
|
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
Button(labelKey) {
|
Button(labelKey) {
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ struct ContentView: View {
|
|||||||
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
||||||
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
||||||
}
|
}
|
||||||
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
|
|
||||||
voiceRecordingVM?.panicWipe()
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isNicknameFieldFocused = false
|
isNicknameFieldFocused = false
|
||||||
@@ -232,7 +229,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
appChromeModel.setPanicPreparation(nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,26 +21,6 @@ extension String {
|
|||||||
return current >= threshold
|
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
|
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
||||||
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
||||||
// form matches too — the token embedded after the scheme is the match.
|
// form matches too — the token embedded after the scheme is the match.
|
||||||
|
|||||||
@@ -38,12 +38,6 @@
|
|||||||
"comment" : "Fallback title when saving a shared link"
|
"comment" : "Fallback title when saving a shared link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fa" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "پیوند اشتراکگذاریشده"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -239,12 +233,6 @@
|
|||||||
"comment" : "Shown when the share payload cannot be encoded"
|
"comment" : "Shown when the share payload cannot be encoded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fa" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "کدگذاری پیوند ناموفق بود"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -440,12 +428,6 @@
|
|||||||
"comment" : "Shown when provided content cannot be shared"
|
"comment" : "Shown when provided content cannot be shared"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fa" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "محتوای قابل اشتراکگذاری وجود ندارد"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -641,12 +623,6 @@
|
|||||||
"comment" : "Shown when the share extension receives no content"
|
"comment" : "Shown when the share extension receives no content"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fa" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "چیزی برای اشتراکگذاری نیست"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -842,12 +818,6 @@
|
|||||||
"comment" : "Confirmation after successfully sharing a link"
|
"comment" : "Confirmation after successfully sharing a link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fa" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -1043,12 +1013,6 @@
|
|||||||
"comment" : "Confirmation after successfully sharing text"
|
"comment" : "Confirmation after successfully sharing text"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fa" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
|
|||||||
@@ -147,44 +147,6 @@ struct AppArchitectureTests {
|
|||||||
#expect(store.teleportedGeo.isEmpty)
|
#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")
|
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||||
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||||
|
|||||||
@@ -572,107 +572,6 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
#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 @MainActor
|
|
||||||
func panicSuspension_invalidatesQueuedMainActorIngress() async {
|
|
||||||
let ble = makeService()
|
|
||||||
let delegate = TransportEventCaptureDelegate()
|
|
||||||
ble.eventDelegate = delegate
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "pre-panic-ingress",
|
|
||||||
sender: "Peer",
|
|
||||||
content: "must not survive panic",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "Me",
|
|
||||||
senderPeerID: PeerID(str: "1122334455667788")
|
|
||||||
)
|
|
||||||
|
|
||||||
// The test already owns MainActor, so this task cannot run until the
|
|
||||||
// synchronous panic boundary below has invalidated its generation.
|
|
||||||
ble._test_emitTransportEvent(.messageReceived(message))
|
|
||||||
ble.suspendForPanicReset()
|
|
||||||
await Task.yield()
|
|
||||||
#expect(delegate.messageIDs.isEmpty)
|
|
||||||
|
|
||||||
ble.completePanicReset(restartServices: false)
|
|
||||||
ble._test_emitTransportEvent(.messageReceived(message))
|
|
||||||
await Task.yield()
|
|
||||||
#expect(delegate.messageIDs == [message.id])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
|
|
||||||
let ble = makeService()
|
|
||||||
let gate = ReceivePacketHandoffGate()
|
|
||||||
ble._test_beforeReceivePacketHandoff = gate.pause
|
|
||||||
ble._test_onReceivePacketHandoff = gate.recordHandoff
|
|
||||||
defer {
|
|
||||||
gate.release()
|
|
||||||
ble._test_beforeReceivePacketHandoff = nil
|
|
||||||
ble._test_onReceivePacketHandoff = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let sender = PeerID(str: "1122334455667788")
|
|
||||||
let packet = makePublicPacket(
|
|
||||||
content: "must not cross panic",
|
|
||||||
sender: sender,
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
)
|
|
||||||
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
|
||||||
#expect(await TestHelpers.waitUntil(
|
|
||||||
{ gate.hasPaused },
|
|
||||||
timeout: TestConstants.longTimeout
|
|
||||||
))
|
|
||||||
|
|
||||||
// Panic closes the lifecycle before waiting for the paused bleQueue
|
|
||||||
// callback. Releasing it afterward lets the callback enqueue its
|
|
||||||
// messageQueue handoff, where the captured generation must be rejected
|
|
||||||
// before packet processing starts.
|
|
||||||
let releaseAfterPanicCloses = Task.detached {
|
|
||||||
while ble._test_isPanicIngressOpen {
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
gate.release()
|
|
||||||
}
|
|
||||||
let panic = Task { @MainActor in
|
|
||||||
ble.suspendForPanicReset()
|
|
||||||
}
|
|
||||||
await panic.value
|
|
||||||
await releaseAfterPanicCloses.value
|
|
||||||
|
|
||||||
#expect(gate.handoffCount == 0)
|
|
||||||
|
|
||||||
// A packet captured under the reopened lifecycle still crosses the
|
|
||||||
// same handoff, proving the test did not merely disable the hook.
|
|
||||||
ble.completePanicReset(restartServices: false)
|
|
||||||
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
|
||||||
#expect(await TestHelpers.waitUntil(
|
|
||||||
{ gate.handoffCount == 1 },
|
|
||||||
timeout: TestConstants.longTimeout
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
@@ -767,48 +666,6 @@ private final class OutboundPacketTap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
|
||||||
private let condition = NSCondition()
|
|
||||||
private var paused = false
|
|
||||||
private var released = false
|
|
||||||
private var recordedHandoffCount = 0
|
|
||||||
|
|
||||||
var hasPaused: Bool {
|
|
||||||
condition.lock()
|
|
||||||
defer { condition.unlock() }
|
|
||||||
return paused
|
|
||||||
}
|
|
||||||
|
|
||||||
var handoffCount: Int {
|
|
||||||
condition.lock()
|
|
||||||
defer { condition.unlock() }
|
|
||||||
return recordedHandoffCount
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
condition.lock()
|
|
||||||
paused = true
|
|
||||||
condition.broadcast()
|
|
||||||
while !released {
|
|
||||||
condition.wait()
|
|
||||||
}
|
|
||||||
condition.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func release() {
|
|
||||||
condition.lock()
|
|
||||||
released = true
|
|
||||||
condition.broadcast()
|
|
||||||
condition.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordHandoff() {
|
|
||||||
condition.lock()
|
|
||||||
recordedHandoffCount += 1
|
|
||||||
condition.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeService() -> BLEService {
|
private func makeService() -> BLEService {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
@@ -867,13 +724,3 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
|||||||
return publicMessages
|
return publicMessages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class TransportEventCaptureDelegate: TransportEventDelegate {
|
|
||||||
private(set) var messageIDs: [String] = []
|
|
||||||
|
|
||||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
|
||||||
guard case .messageReceived(let message) = event else { return }
|
|
||||||
messageIDs.append(message.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -188,21 +188,6 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel(
|
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
panicMediaWipe: (() throws -> Void)? = nil,
|
|
||||||
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
|
||||||
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
|
||||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
@@ -30,10 +26,7 @@ private func makeTestableViewModel(
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport,
|
transport: transport
|
||||||
panicMediaWipe: panicMediaWipe,
|
|
||||||
panicRecoveryOperations: panicRecoveryOperations,
|
|
||||||
panicNetworkLifecycle: panicNetworkLifecycle
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -653,25 +646,6 @@ struct ChatViewModelFormattingTests {
|
|||||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
#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
|
@Test @MainActor
|
||||||
func formatMessageHeader_formatsSenderHeader() async {
|
func formatMessageHeader_formatsSenderHeader() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
@@ -1123,92 +1097,6 @@ struct ChatViewModelBluetoothTests {
|
|||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
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
|
@Test @MainActor
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -323,32 +323,6 @@ struct MessageFormattingEngineTests {
|
|||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
#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
|
@MainActor
|
||||||
|
|||||||
@@ -116,87 +116,4 @@ struct MessageRateLimiterTests {
|
|||||||
#expect(plain)
|
#expect(plain)
|
||||||
#expect(!plainExhausted)
|
#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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,65 +290,7 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#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
|
// 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? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("PreviewKeychainManager Tests")
|
@Suite("PreviewKeychainManager Tests")
|
||||||
struct PreviewKeychainManagerTests {
|
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")
|
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||||
func previewKeychainManagerRoundTripsData() {
|
func previewKeychainManagerRoundTripsData() {
|
||||||
let manager = PreviewKeychainManager()
|
let manager = PreviewKeychainManager()
|
||||||
|
|||||||
@@ -370,132 +370,6 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
#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) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
burstMaxDelay: 0
|
burstMaxDelay: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
service.start()
|
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
|
|
||||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||||
@@ -84,7 +83,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||||
XCTAssertEqual(scheduler.intervals, [17, 17])
|
XCTAssertEqual(scheduler.intervals, [17])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||||
@@ -98,12 +97,11 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 21
|
loopMaxInterval: 21
|
||||||
)
|
)
|
||||||
|
|
||||||
service.start()
|
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [21, 21])
|
XCTAssertEqual(scheduler.intervals, [21])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||||
@@ -117,45 +115,11 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 22
|
loopMaxInterval: 22
|
||||||
)
|
)
|
||||||
|
|
||||||
service.start()
|
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [22, 22])
|
XCTAssertEqual(scheduler.intervals, [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 {
|
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||||
|
|||||||
@@ -91,53 +91,6 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
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(
|
private func makeService(
|
||||||
permission: LocationChannelManager.PermissionState,
|
permission: LocationChannelManager.PermissionState,
|
||||||
favorites: Set<Data>
|
favorites: Set<Data>
|
||||||
@@ -151,7 +104,6 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
let torController = MockNetworkActivationTorController()
|
let torController = MockNetworkActivationTorController()
|
||||||
let relayController = MockNetworkActivationRelayController()
|
let relayController = MockNetworkActivationRelayController()
|
||||||
let proxyController = MockNetworkActivationProxyController()
|
let proxyController = MockNetworkActivationProxyController()
|
||||||
let reachability = MockNetworkActivationReachability()
|
|
||||||
let notificationCenter = NotificationCenter()
|
let notificationCenter = NotificationCenter()
|
||||||
let service = NetworkActivationService(
|
let service = NetworkActivationService(
|
||||||
storage: storage,
|
storage: storage,
|
||||||
@@ -159,7 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||||
permissionProvider: { permissionSubject.value },
|
permissionProvider: { permissionSubject.value },
|
||||||
mutualFavoritesProvider: { favoritesSubject.value },
|
mutualFavoritesProvider: { favoritesSubject.value },
|
||||||
reachabilityMonitor: reachability,
|
reachabilityMonitor: AlwaysReachableMonitor(),
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -169,7 +121,6 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
service: service,
|
service: service,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
favoritesSubject: favoritesSubject,
|
favoritesSubject: favoritesSubject,
|
||||||
reachability: reachability,
|
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -197,38 +148,12 @@ private struct NetworkActivationTestContext {
|
|||||||
let service: NetworkActivationService
|
let service: NetworkActivationService
|
||||||
let storage: UserDefaults
|
let storage: UserDefaults
|
||||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||||
let reachability: MockNetworkActivationReachability
|
|
||||||
let torController: MockNetworkActivationTorController
|
let torController: MockNetworkActivationTorController
|
||||||
let relayController: MockNetworkActivationRelayController
|
let relayController: MockNetworkActivationRelayController
|
||||||
let proxyController: MockNetworkActivationProxyController
|
let proxyController: MockNetworkActivationProxyController
|
||||||
let notificationCenter: NotificationCenter
|
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
|
@MainActor
|
||||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||||
private(set) var autoStartAllowedValues: [Bool] = []
|
private(set) var autoStartAllowedValues: [Bool] = []
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
|
|||||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func start() { startCalled = true }
|
func start() { startCalled = true }
|
||||||
func stop() { startCalled = false }
|
|
||||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
private let startError: Error?
|
private let startError: Error?
|
||||||
private(set) var finishStarted = false
|
private(set) var finishStarted = false
|
||||||
private(set) var cancelCount = 0
|
private(set) var cancelCount = 0
|
||||||
private(set) var panicCancelCount = 0
|
|
||||||
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||||
|
|
||||||
init(startError: Error? = nil) {
|
init(startError: Error? = nil) {
|
||||||
@@ -84,10 +83,6 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
cancelCount += 1
|
cancelCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func panicCancelSynchronously() {
|
|
||||||
panicCancelCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveFinish(with url: URL?) {
|
func resolveFinish(with url: URL?) {
|
||||||
let continuation = finishContinuation
|
let continuation = finishContinuation
|
||||||
finishContinuation = nil
|
finishContinuation = nil
|
||||||
@@ -209,57 +204,4 @@ struct VoiceCaptureSessionTests {
|
|||||||
}
|
}
|
||||||
#expect(viewModel.state == .idle)
|
#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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,35 +360,6 @@ struct VoiceRecorderTests {
|
|||||||
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
#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(
|
private func verifyFailedStart(
|
||||||
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||||
expectedPrepareCalls: Int,
|
expectedPrepareCalls: Int,
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
- 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.
|
||||||
|
|
||||||
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.
|
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
|
## 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. 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.
|
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.
|
||||||
|
|
||||||
## Release Review Checklist
|
## Release Review Checklist
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user