Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84e79f7641 | ||
|
|
f6081dad31 | ||
|
|
f0c8b4e2ea | ||
|
|
5ba6f32f61 | ||
|
|
43c7fc52ba | ||
|
|
ca18843bb0 | ||
|
|
593fd7d737 | ||
|
|
733098bb63 | ||
|
|
820c933958 | ||
|
|
b205a55523 | ||
|
|
f8ab0a7dc0 | ||
|
|
304460ee83 | ||
|
|
b7c6f42b3a | ||
|
|
c8479c15e3 | ||
|
|
84d315e62d | ||
|
|
dd6b624cae | ||
|
|
021af3a22d | ||
|
|
8c9c1cc6ac | ||
|
|
55d51ba084 | ||
|
|
dc99d641c8 | ||
|
|
020de96519 | ||
|
|
d499c3e415 | ||
|
|
4baeaab717 | ||
|
|
74b8a47d02 | ||
|
|
ef848857b7 | ||
|
|
d5cf64f99e | ||
|
|
35fb9fdd42 | ||
|
|
3e9230229d | ||
|
|
78a291ab77 | ||
|
|
eacd8f0750 | ||
|
|
6886035632 | ||
|
|
e642f696cb | ||
|
|
3f677776bb | ||
|
|
5dffb04912 | ||
|
|
81c45e9649 | ||
|
|
963d3a089f | ||
|
|
f79c2e3e9f | ||
|
|
0c3136282e | ||
|
|
b043324035 | ||
|
|
bbe5e1ef4e | ||
|
|
28ffc53f3f | ||
|
|
8415c52913 | ||
|
|
a0c517c018 | ||
|
|
81a10f73f0 | ||
|
|
87910541ef | ||
|
|
f9032cf2b9 | ||
|
|
d4f0c49787 | ||
|
|
2360140760 | ||
|
|
70229f0be1 | ||
|
|
cee2bcd535 | ||
|
|
3c610a83cd | ||
|
|
60be88a4f5 | ||
|
|
ede6368296 | ||
|
|
276cde44e7 | ||
|
|
201dbac49a | ||
|
|
5fdc15d5af |
@@ -0,0 +1,30 @@
|
|||||||
|
name: Dead Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
periphery:
|
||||||
|
name: Periphery scan
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||||
|
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Periphery
|
||||||
|
# homebrew-core formula; the peripheryapp tap lags years behind.
|
||||||
|
run: brew install periphery
|
||||||
|
|
||||||
|
- name: Scan for dead code
|
||||||
|
# Config comes from .periphery.yml; known findings (mostly iOS-only
|
||||||
|
# code invisible to a macOS scan) are suppressed by the committed
|
||||||
|
# baseline. --strict fails the step when NEW dead code appears.
|
||||||
|
run: periphery scan --strict --disable-update-check
|
||||||
@@ -131,10 +131,10 @@ jobs:
|
|||||||
echo "No coverage data found; skipping summary."
|
echo "No coverage data found; skipping summary."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# SPM tests above only compile the macOS slice; this job covers the
|
# SPM tests do not link the shipping app targets. This job covers the
|
||||||
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
# iOS-conditional paths and both universal Release link configurations.
|
||||||
ios-build:
|
ios-build:
|
||||||
name: Build iOS app (simulator)
|
name: Build Release apps (universal)
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
@@ -143,14 +143,29 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Build iOS (simulator, no signing)
|
- name: Build iOS (simulator, no signing)
|
||||||
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
# Build both simulator architectures so CI validates every vendored
|
||||||
|
# Arti simulator slice and the configuration that ships.
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
xcodebuild -project bitchat.xcodeproj \
|
xcodebuild -project bitchat.xcodeproj \
|
||||||
-scheme "bitchat (iOS)" \
|
-scheme "bitchat (iOS)" \
|
||||||
|
-configuration Release \
|
||||||
-sdk iphonesimulator \
|
-sdk iphonesimulator \
|
||||||
-destination 'generic/platform=iOS Simulator' \
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
ARCHS=arm64 \
|
ARCHS='arm64 x86_64' \
|
||||||
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
build
|
||||||
|
|
||||||
|
- name: Build macOS (universal, no signing)
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild -project bitchat.xcodeproj \
|
||||||
|
-scheme "bitchat (macOS)" \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=macOS' \
|
||||||
|
ARCHS='arm64 x86_64' \
|
||||||
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
build
|
build
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
|
||||||
|
#
|
||||||
|
# CI runs the macOS scheme only (an iOS scan needs a device destination and
|
||||||
|
# doubles the build time). macOS-only scans falsely flag iOS-only code —
|
||||||
|
# state restoration, screenshot handlers, background BLE sampling — so those
|
||||||
|
# findings live in .periphery.baseline.json rather than being "fixed".
|
||||||
|
# When auditing by hand, scan BOTH schemes and intersect:
|
||||||
|
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
|
||||||
|
project: bitchat.xcodeproj
|
||||||
|
schemes:
|
||||||
|
- bitchat (macOS)
|
||||||
|
retain_swift_ui_previews: true
|
||||||
|
# Codable properties are (de)serialized via synthesized conformances the
|
||||||
|
# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle
|
||||||
|
# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk —
|
||||||
|
# and slipped past its baselined USR. Retaining Codable properties outright
|
||||||
|
# is deterministic; a truly-dead Codable field is a persisted-format change
|
||||||
|
# anyway, never a safe mechanical delete.
|
||||||
|
retain_codable_properties: true
|
||||||
|
relative_results: true
|
||||||
|
baseline: .periphery.baseline.json
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||||
excluded:
|
excluded:
|
||||||
- .build
|
- .build
|
||||||
|
- .claude
|
||||||
- .swiftpm
|
- .swiftpm
|
||||||
- .DerivedData
|
- .DerivedData
|
||||||
- DerivedData
|
- DerivedData
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.4
|
MARKETING_VERSION = 1.7.1
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -1,165 +1,155 @@
|
|||||||
# bitchat Privacy Policy
|
# bitchat Privacy Policy
|
||||||
|
|
||||||
*Last updated: June 2026*
|
*Last updated: July 2026*
|
||||||
|
|
||||||
## Our Commitment
|
## Our Commitment
|
||||||
|
|
||||||
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
|
bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
|
||||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
|
||||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
- **No sale of data** — the project does not sell user data or build advertising profiles.
|
||||||
- **Open source** - You can verify these claims by reading our code
|
- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code.
|
||||||
|
|
||||||
## What Information bitchat Stores
|
## What bitchat Stores on Your Device
|
||||||
|
|
||||||
### On Your Device Only
|
1. **Identity and cryptographic keys**
|
||||||
|
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||||
|
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||||
|
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
||||||
|
|
||||||
1. **Identity Keys**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
- Stored locally in your device's secure storage
|
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
||||||
- Allows you to maintain "favorite" relationships across app restarts
|
|
||||||
- Private keys never leave your device; public keys are shared when needed for messaging
|
|
||||||
|
|
||||||
2. **Nickname**
|
3. **Private group state**
|
||||||
- The display name you choose (or auto-generated)
|
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||||
- Stored only on your device
|
- Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app.
|
||||||
- Shared with peers you communicate with
|
|
||||||
|
|
||||||
3. **Message History** (if enabled)
|
4. **Queued and carried private messages**
|
||||||
- When room owners enable retention, messages are saved locally
|
- An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain.
|
||||||
- Stored encrypted on your device
|
- A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content.
|
||||||
- You can delete this at any time
|
- A panic wipe deletes both stores.
|
||||||
|
|
||||||
4. **Favorite Peers**
|
5. **Recent public mesh messages and notices**
|
||||||
- Public keys of peers you mark as favorites
|
- Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch.
|
||||||
- Stored only on your device
|
- Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable.
|
||||||
- Allows you to recognize these peers in future sessions
|
- These items are public to the mesh or board where they are posted; they are not confidential messages.
|
||||||
|
|
||||||
5. **Optional Location Channel State**
|
6. **Media attachments**
|
||||||
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
|
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||||
- Stored locally on your device so the location-channel UI can restore your choices
|
- Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk.
|
||||||
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
|
|
||||||
- Exact latitude and longitude are not persisted by bitchat
|
|
||||||
|
|
||||||
### Temporary Session Data
|
7. **Optional location-channel state**
|
||||||
|
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
|
||||||
|
- Per-geohash Nostr identities are derived locally from a device seed stored in the keychain.
|
||||||
|
- bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages.
|
||||||
|
|
||||||
During each session, bitchat temporarily maintains:
|
## Temporary Session Data
|
||||||
- Active peer connections (forgotten when app closes)
|
|
||||||
- Routing information for message delivery
|
|
||||||
- Cached messages for offline peers (12 hours max)
|
|
||||||
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
|
|
||||||
|
|
||||||
## What Information is Shared
|
While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above.
|
||||||
|
|
||||||
### With Other bitchat Users
|
## What Is Shared
|
||||||
|
|
||||||
When you use bitchat, nearby peers can see:
|
### With Nearby Mesh Users
|
||||||
- Your chosen nickname
|
|
||||||
- Your ephemeral public key (changes each session)
|
|
||||||
- Messages you send to public rooms or directly to them
|
|
||||||
- Your approximate Bluetooth signal strength (for connection quality)
|
|
||||||
|
|
||||||
### With Room Members
|
Depending on the feature you use, nearby peers can receive:
|
||||||
|
|
||||||
When you join a password-protected room:
|
- Your chosen nickname and public Noise/signing identity material.
|
||||||
- Your messages are visible to others with the password
|
- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell.
|
||||||
- Your nickname appears in the member list
|
- Public mesh messages, public notices, and group-control packets you intentionally send.
|
||||||
- Room owners can see you've joined
|
- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry.
|
||||||
|
- Radio metadata available to the receiver, such as approximate Bluetooth signal strength.
|
||||||
|
|
||||||
### With Nostr Relays (Optional Features)
|
Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state.
|
||||||
|
|
||||||
If you enable Nostr-backed features:
|
### With Private Group Members
|
||||||
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
|
|
||||||
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
|
|
||||||
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
|
|
||||||
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
|
|
||||||
|
|
||||||
## What We DON'T Do
|
Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members.
|
||||||
|
|
||||||
bitchat **never**:
|
### With Nostr Relays and Internet Gateways
|
||||||
- Collects personal information
|
|
||||||
- Sells or shares your exact GPS location
|
|
||||||
- Stores data on servers we operate
|
|
||||||
- Sells your data to advertisers or data brokers
|
|
||||||
- Uses analytics or telemetry
|
|
||||||
- Creates user profiles
|
|
||||||
- Requires registration
|
|
||||||
|
|
||||||
## Encryption
|
Internet-backed features are optional. When enabled or used:
|
||||||
|
|
||||||
All private messages use end-to-end encryption:
|
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||||
- **X25519** for key exchange
|
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||||
- **AES-256-GCM** for message encryption
|
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||||
- **Ed25519** for digital signatures
|
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||||
- **Argon2id** for password-protected rooms
|
- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices.
|
||||||
|
|
||||||
## Your Rights
|
Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy.
|
||||||
|
|
||||||
You have complete control:
|
## Location and Apple Services
|
||||||
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
|
|
||||||
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
|
|
||||||
- **No Account**: No account record exists for you to delete from us
|
|
||||||
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
|
|
||||||
|
|
||||||
## Bluetooth & Permissions
|
Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels.
|
||||||
|
|
||||||
bitchat requires Bluetooth permission to function:
|
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
|
||||||
- Used only for peer-to-peer communication
|
- A selected geohash can still reveal an approximate area to peers and relays.
|
||||||
- Bluetooth is not used for tracking
|
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
|
||||||
- You can revoke this permission at any time in system settings
|
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app.
|
||||||
|
|
||||||
## Location Permission
|
## Microphone, Camera, and Media Permissions
|
||||||
|
|
||||||
Location permission is optional and is used only for location channels:
|
- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below.
|
||||||
- Used to compute local geohash channels and display names
|
- Voice-note and live-audio files can remain in Application Support under the media retention rules above.
|
||||||
- Requested as when-in-use permission
|
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
|
||||||
- Exact coordinates are not shared in messages or stored by bitchat
|
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
|
||||||
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
|
|
||||||
- You can revoke this permission at any time in system settings
|
## Cryptography
|
||||||
|
|
||||||
|
Private and public features use different protections:
|
||||||
|
|
||||||
|
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||||
|
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||||
|
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
||||||
|
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||||
|
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||||
|
|
||||||
|
No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it.
|
||||||
|
|
||||||
|
## Data Retention Summary
|
||||||
|
|
||||||
|
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
|
||||||
|
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
|
||||||
|
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
|
||||||
|
- **Recent public mesh gossip:** up to 15 minutes.
|
||||||
|
- **Public board posts and tombstones:** until expiry, at most seven days.
|
||||||
|
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
|
||||||
|
- **Nostr data:** according to the policies of the relays that receive it.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||||
|
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||||
|
- **No account:** The project operates no account record for you to request or export.
|
||||||
|
|
||||||
|
## What the Project Does Not Do
|
||||||
|
|
||||||
|
bitchat does not:
|
||||||
|
|
||||||
|
- Operate an account database or project-owned messaging backend.
|
||||||
|
- Include advertising, analytics, or tracking SDKs.
|
||||||
|
- Sell user data or create advertising profiles.
|
||||||
|
- Include exact GPS coordinates in bitchat mesh or Nostr message payloads.
|
||||||
|
|
||||||
## Children's Privacy
|
## Children's Privacy
|
||||||
|
|
||||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed.
|
||||||
|
|
||||||
## Data Retention
|
|
||||||
|
|
||||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
|
||||||
- **Identity Key**: Persists until you delete the app
|
|
||||||
- **Favorites**: Persist until you remove them or delete the app
|
|
||||||
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
|
|
||||||
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
|
|
||||||
- **Everything Else**: Exists only during active sessions
|
|
||||||
|
|
||||||
## Security Measures
|
|
||||||
|
|
||||||
- All communication is encrypted
|
|
||||||
- No accounts or company servers
|
|
||||||
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
|
|
||||||
- Open source code for public audit
|
|
||||||
- Regular security updates
|
|
||||||
- Cryptographic signatures prevent tampering
|
|
||||||
|
|
||||||
## Changes to This Policy
|
## Changes to This Policy
|
||||||
|
|
||||||
If we update this policy:
|
Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device.
|
||||||
- The "Last updated" date will change
|
|
||||||
- The updated policy will be included in the app
|
|
||||||
- No retroactive changes can make us collect data already held only in your app
|
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
bitchat is an open source project. For privacy questions:
|
bitchat is an open source project. For privacy questions:
|
||||||
- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main)
|
|
||||||
- Open an issue on GitHub
|
|
||||||
- Join the discussion in public rooms
|
|
||||||
|
|
||||||
## Philosophy
|
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
|
||||||
|
- Open an issue on GitHub.
|
||||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
|
*This policy is released into the public domain under The Unlicense, like the project itself.*
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
ShareViewController.swift,
|
Info.plist,
|
||||||
|
bitchatShareExtension.entitlements,
|
||||||
);
|
);
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||||
};
|
};
|
||||||
@@ -258,9 +259,13 @@
|
|||||||
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
||||||
|
7E9B64F63F93443FB7BA12DF /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
|
);
|
||||||
name = bitchatShareExtension;
|
name = bitchatShareExtension;
|
||||||
productName = bitchatShareExtension;
|
productName = bitchatShareExtension;
|
||||||
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
||||||
@@ -332,6 +337,7 @@
|
|||||||
es,
|
es,
|
||||||
ar,
|
ar,
|
||||||
de,
|
de,
|
||||||
|
fa,
|
||||||
fr,
|
fr,
|
||||||
he,
|
he,
|
||||||
id,
|
id,
|
||||||
@@ -388,6 +394,13 @@
|
|||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
|||||||
@@ -36,34 +36,12 @@ enum AppEvent: Sendable, Equatable {
|
|||||||
actor AppEventStream {
|
actor AppEventStream {
|
||||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||||
|
|
||||||
func stream() -> AsyncStream<AppEvent> {
|
|
||||||
let id = UUID()
|
|
||||||
return AsyncStream { continuation in
|
|
||||||
continuations[id] = continuation
|
|
||||||
continuation.onTermination = { [id] _ in
|
|
||||||
Task {
|
|
||||||
await self.removeContinuation(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func emit(_ event: AppEvent) {
|
func emit(_ event: AppEvent) {
|
||||||
for continuation in continuations.values {
|
for continuation in continuations.values {
|
||||||
continuation.yield(event)
|
continuation.yield(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func finish() {
|
|
||||||
for continuation in continuations.values {
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
continuations.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeContinuation(_ id: UUID) {
|
|
||||||
continuations.removeValue(forKey: id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identity key for a direct conversation. Equality and hashing use the
|
/// Identity key for a direct conversation. Equality and hashing use the
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ final class AppChromeModel: ObservableObject {
|
|||||||
@Published var showingFingerprintFor: PeerID?
|
@Published var showingFingerprintFor: PeerID?
|
||||||
@Published var isAppInfoPresented = false
|
@Published var isAppInfoPresented = false
|
||||||
@Published var isLocationChannelsSheetPresented = false
|
@Published var isLocationChannelsSheetPresented = false
|
||||||
|
@Published var isNoticesSheetPresented = false
|
||||||
|
/// When the sheet is opened for "notes left here" (empty mesh timeline),
|
||||||
|
/// it should land on the geo tab instead of the channel-derived default.
|
||||||
|
@Published var noticesSheetPrefersGeoTab = false
|
||||||
@Published var showBluetoothAlert = false
|
@Published var showBluetoothAlert = false
|
||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
@@ -17,6 +21,9 @@ 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)
|
||||||
@@ -62,6 +69,11 @@ final class AppChromeModel: ObservableObject {
|
|||||||
isAppInfoPresented = true
|
isAppInfoPresented = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func presentNotices(geoTab: Bool = false) {
|
||||||
|
noticesSheetPrefersGeoTab = geoTab
|
||||||
|
isNoticesSheetPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the mesh topology map model from the transport's gossiped
|
/// Builds the mesh topology map model from the transport's gossiped
|
||||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||||
@@ -88,7 +100,12 @@ final class AppChromeModel: ObservableObject {
|
|||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||||
|
prepareForPanic = preparation
|
||||||
|
}
|
||||||
|
|
||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
|
prepareForPanic?()
|
||||||
chatViewModel.panicClearAllData()
|
chatViewModel.panicClearAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||||
/// and `ChatViewModel` observe and mutate it through its intent API.
|
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||||
let conversations: ConversationStore
|
let conversations: ConversationStore
|
||||||
let peerIdentityStore: PeerIdentityStore
|
|
||||||
let locationPresenceStore: LocationPresenceStore
|
|
||||||
let publicChatModel: PublicChatModel
|
let publicChatModel: PublicChatModel
|
||||||
let privateInboxModel: PrivateInboxModel
|
let privateInboxModel: PrivateInboxModel
|
||||||
let privateConversationModel: PrivateConversationModel
|
let privateConversationModel: PrivateConversationModel
|
||||||
@@ -28,6 +26,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationChannelsModel: LocationChannelsModel
|
let locationChannelsModel: LocationChannelsModel
|
||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let appChromeModel: AppChromeModel
|
let appChromeModel: AppChromeModel
|
||||||
|
let boardAlertsModel: BoardAlertsModel
|
||||||
|
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -41,7 +40,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
@@ -50,8 +49,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationPresenceStore = LocationPresenceStore()
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
let locationManager = LocationChannelManager.shared
|
let locationManager = LocationChannelManager.shared
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
self.peerIdentityStore = peerIdentityStore
|
|
||||||
self.locationPresenceStore = locationPresenceStore
|
|
||||||
self.chatViewModel = ChatViewModel(
|
self.chatViewModel = ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
@@ -91,13 +88,34 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
privateInboxModel: self.privateInboxModel
|
privateInboxModel: self.privateInboxModel
|
||||||
)
|
)
|
||||||
|
let chatViewModel = self.chatViewModel
|
||||||
|
self.boardAlertsModel = BoardAlertsModel(
|
||||||
|
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
||||||
|
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
||||||
|
dependencies: BoardAlertsModel.Dependencies(
|
||||||
|
isOwnPost: { post in
|
||||||
|
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
||||||
|
return !key.isEmpty && key == post.authorSigningKey
|
||||||
|
},
|
||||||
|
emitSystemLine: { content, geohash in
|
||||||
|
if geohash.isEmpty {
|
||||||
|
chatViewModel.addMeshOnlySystemMessage(content)
|
||||||
|
} else {
|
||||||
|
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
if chatViewModel.networkActivationAllowed {
|
||||||
|
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
|
||||||
@@ -136,12 +154,14 @@ 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()
|
||||||
@@ -160,6 +180,7 @@ 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)
|
||||||
@@ -202,7 +223,17 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel.applicationWillTerminate()
|
chatViewModel.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
func handleNotificationResponse(
|
||||||
|
identifier: String,
|
||||||
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
|
chatViewModel.sendMeshWave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
record(.notificationOpened(peerID: peerID))
|
record(.notificationOpened(peerID: peerID))
|
||||||
chatViewModel.startPrivateChat(with: peerID)
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
@@ -249,6 +280,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -257,6 +290,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -265,6 +300,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -273,6 +310,8 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -289,21 +328,30 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||||
|
let clearSharedContent = {
|
||||||
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||||
|
// A partial or malformed handoff must not linger in the shared
|
||||||
|
// app-group container indefinitely.
|
||||||
|
clearSharedContent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||||
|
clearSharedContent()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||||
|
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
clearSharedContent()
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
|
|
||||||
switch contentKind {
|
switch contentKind {
|
||||||
case .url:
|
case .url:
|
||||||
@@ -327,7 +375,9 @@ private extension AppRuntime {
|
|||||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
lastNostrRelayConnectedState = isConnected
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
guard started, becameConnected else { return }
|
guard chatViewModel.networkActivationAllowed,
|
||||||
|
started,
|
||||||
|
becameConnected else { return }
|
||||||
|
|
||||||
let isInitialConnection = !didHandleInitialNostrConnection
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
didHandleInitialNostrConnection = true
|
didHandleInitialNostrConnection = true
|
||||||
|
|||||||
@@ -225,8 +225,25 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
guard let current else { return false }
|
guard let current else { return false }
|
||||||
if current == new { return true }
|
if current == new { return true }
|
||||||
|
|
||||||
|
// Never downgrade to a weaker delivery state. Ordering of certainty:
|
||||||
|
// sending < sent < carried < delivered < read. A late `.sent` write
|
||||||
|
// (e.g. the optimistic stamp after routing) must not clobber the
|
||||||
|
// `.carried` the router already set when it handed a copy to a
|
||||||
|
// courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous
|
||||||
|
// failure is weaker than a confirmed recipient receipt too, so it may
|
||||||
|
// not replace `.delivered`/`.read`. Same for the
|
||||||
|
// `.sending` stamp a pre-handshake resend emits asynchronously: it
|
||||||
|
// can land after the message already reached `.sent`, and "Sent" was
|
||||||
|
// already truthful. (`.failed` → `.sending` stays allowed so a real
|
||||||
|
// failure retry is visible.)
|
||||||
switch (current, new) {
|
switch (current, new) {
|
||||||
case (.read, .delivered), (.read, .sent):
|
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed):
|
||||||
|
return true
|
||||||
|
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
|
||||||
|
return true
|
||||||
|
case (.carried, .sent), (.carried, .sending):
|
||||||
|
return true
|
||||||
|
case (.sent, .sending):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -796,16 +813,6 @@ extension ConversationStore {
|
|||||||
return messageIDs
|
return messageIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every direct conversation (panic clear).
|
|
||||||
func removeAllDirectConversations() {
|
|
||||||
let directIDs = conversationIDs.filter { id in
|
|
||||||
if case .direct = id { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for id in directIDs {
|
|
||||||
removeConversation(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Diagnostics support
|
// MARK: - Diagnostics support
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
@Published private(set) var currentNickname: String
|
@Published private(set) var currentNickname: String
|
||||||
@Published private(set) var isBatchingPublic = false
|
@Published private(set) var isBatchingPublic = false
|
||||||
@Published private(set) var canSendMediaInCurrentContext = true
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
/// Who is talking live in the public mesh channel right now (floor
|
||||||
|
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||||
|
@Published private(set) var activeLiveVoiceTalker: String?
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private let privateConversationModel: PrivateConversationModel
|
private let privateConversationModel: PrivateConversationModel
|
||||||
@@ -150,6 +153,17 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
chatViewModel.sendVoiceNote(at: url)
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||||
|
/// peer can hear it now, classic voice note otherwise.
|
||||||
|
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||||
|
chatViewModel.makeVoiceCaptureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this message is a live voice burst still streaming in.
|
||||||
|
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
|
||||||
|
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
func cancelMediaSend(messageID: String) {
|
func cancelMediaSend(messageID: String) {
|
||||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -175,6 +189,10 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$isBatchingPublic)
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
|
chatViewModel.$activePublicVoiceTalker
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$activeLiveVoiceTalker)
|
||||||
|
|
||||||
conversations.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
private let manager: LocationChannelManager
|
private let manager: LocationChannelManager
|
||||||
private let network: NetworkActivationService
|
private let network: NetworkActivationService
|
||||||
private let gateway: GatewayService
|
private let gateway: GatewayService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
manager: LocationChannelManager? = nil,
|
manager: LocationChannelManager? = nil,
|
||||||
@@ -102,10 +100,6 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.setUserTorEnabled(enabled)
|
network.setUserTorEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGatewayEnabled(_ enabled: Bool) {
|
|
||||||
gateway.setEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshMeshChannelsIfNeeded() {
|
func refreshMeshChannelsIfNeeded() {
|
||||||
guard case .mesh = selectedChannel,
|
guard case .mesh = selectedChannel,
|
||||||
permissionState == .authorized,
|
permissionState == .authorized,
|
||||||
|
|||||||
@@ -7,45 +7,146 @@ 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?) {
|
||||||
currentGeohash = geohash?.lowercased()
|
let normalized = geohash?.lowercased()
|
||||||
|
if currentGeohash != normalized {
|
||||||
|
// Presence markers are scoped to the active geohash channel.
|
||||||
|
clearTeleportedGeo()
|
||||||
|
clearGeoNicknames()
|
||||||
|
}
|
||||||
|
currentGeohash = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
guard geoNicknameCapacity > 0 else {
|
||||||
|
clearGeoNicknames()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
if geoNicknames[key] != nil {
|
||||||
|
geoNicknames[key] = nickname
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||||
|
geoNicknameOrder.removeFirst()
|
||||||
|
geoNicknames.removeValue(forKey: oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
geoNicknames[key] = nickname
|
||||||
|
geoNicknameOrder.append(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
geoNicknames = Dictionary(
|
guard geoNicknameCapacity > 0 else {
|
||||||
uniqueKeysWithValues: nicknames.map { key, value in
|
clearGeoNicknames()
|
||||||
(key.lowercased(), value)
|
return
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var ordered: [String] = []
|
||||||
|
var normalized: [String: String] = [:]
|
||||||
|
for (key, value) in nicknames {
|
||||||
|
let lower = key.lowercased()
|
||||||
|
guard seen.insert(lower).inserted else { continue }
|
||||||
|
ordered.append(lower)
|
||||||
|
normalized[lower] = value
|
||||||
|
}
|
||||||
|
if ordered.count > geoNicknameCapacity {
|
||||||
|
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||||
|
ordered = kept
|
||||||
|
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
|
||||||
|
normalized[key].map { (key, $0) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
geoNicknameOrder = ordered
|
||||||
|
geoNicknames = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearGeoNicknames() {
|
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) {
|
||||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
guard teleportedGeoCapacity > 0 else {
|
||||||
|
clearTeleportedGeo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
guard !teleportedGeo.contains(key) else { return }
|
||||||
|
|
||||||
|
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||||
|
teleportedGeoOrder.removeFirst()
|
||||||
|
teleportedGeo.remove(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
teleportedGeo.insert(key)
|
||||||
|
teleportedGeoOrder.append(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTeleported(_ pubkeyHex: String) {
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
let key = pubkeyHex.lowercased()
|
||||||
|
teleportedGeo.remove(key)
|
||||||
|
teleportedGeoOrder.removeAll { $0 == key }
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
guard teleportedGeoCapacity > 0 else {
|
||||||
|
clearTeleportedGeo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var ordered: [String] = []
|
||||||
|
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||||
|
seen.insert(key)
|
||||||
|
ordered.append(key)
|
||||||
|
}
|
||||||
|
if ordered.count > teleportedGeoCapacity {
|
||||||
|
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||||
|
}
|
||||||
|
teleportedGeoOrder = ordered
|
||||||
|
teleportedGeo = Set(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||||
|
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||||
|
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTeleportedGeo() {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// NearbyNotesCounter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Counts unexpired location notes left at the user's current building-level
|
||||||
|
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
|
||||||
|
// subscribes while a view holds it active, and only when location notes are
|
||||||
|
// enabled and location permission is already granted (it never prompts).
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NearbyNotesCounter: ObservableObject {
|
||||||
|
static let shared = NearbyNotesCounter()
|
||||||
|
|
||||||
|
@Published private(set) var noteCount = 0
|
||||||
|
/// Whether an explicit notes act (the empty-timeline "check for notes"
|
||||||
|
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
|
||||||
|
/// unlocked the counter this session. Until then nothing subscribes:
|
||||||
|
/// merely looking at the mesh timeline must not open a building-precision
|
||||||
|
/// relay REQ that leaks location passively.
|
||||||
|
@Published private(set) var revealed = false
|
||||||
|
|
||||||
|
private var manager: LocationNotesManager?
|
||||||
|
private var managerCancellable: AnyCancellable?
|
||||||
|
private var channelsCancellable: AnyCancellable?
|
||||||
|
private var permissionCancellable: AnyCancellable?
|
||||||
|
private var settingCancellable: AnyCancellable?
|
||||||
|
private var activeHolders = 0
|
||||||
|
private let locationManager: LocationChannelManager
|
||||||
|
private let managerFactory: @MainActor (String) -> LocationNotesManager
|
||||||
|
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
locationManager: LocationChannelManager = .shared,
|
||||||
|
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
|
||||||
|
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
|
||||||
|
) {
|
||||||
|
self.locationManager = locationManager
|
||||||
|
self.managerFactory = managerFactory
|
||||||
|
self.releaseManager = releaseManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the empty-timeline "check for notes" hint should render.
|
||||||
|
/// The permission gate matters: `retarget()` never subscribes without
|
||||||
|
/// location authorization, so offering the hint to an unauthorized
|
||||||
|
/// install would be a silent dead-end — tap, `revealed` flips, the hint
|
||||||
|
/// vanishes, and nothing else happens for the session. The hint never
|
||||||
|
/// prompts; it simply stays hidden until permission exists. The caller
|
||||||
|
/// passes its own observed permission state so the hint re-renders when
|
||||||
|
/// authorization changes.
|
||||||
|
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
|
||||||
|
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the one explicit act that lets the counter subscribe. Sticky for
|
||||||
|
/// the rest of the session (the singleton's lifetime); `deactivate()`
|
||||||
|
/// deliberately does not reset it.
|
||||||
|
func reveal() {
|
||||||
|
guard !revealed else { return }
|
||||||
|
revealed = true
|
||||||
|
retarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begins (or keeps) the notes subscription for the current building
|
||||||
|
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
|
||||||
|
/// hold it.
|
||||||
|
func activate() {
|
||||||
|
activeHolders += 1
|
||||||
|
guard activeHolders == 1 else { return }
|
||||||
|
channelsCancellable = locationManager.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
|
// CoreLocation can revoke authorization while the view remains
|
||||||
|
// mounted. `availableChannels` deliberately retains its last value,
|
||||||
|
// so permission must be an independent invalidation signal or the
|
||||||
|
// building REQ survives on stale coordinates.
|
||||||
|
permissionCancellable = locationManager.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
|
// The app-info kill switch must take effect immediately, not on the
|
||||||
|
// next location change or remount.
|
||||||
|
settingCancellable = NotificationCenter.default
|
||||||
|
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
|
retarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
activeHolders = max(0, activeHolders - 1)
|
||||||
|
guard activeHolders == 0 else { return }
|
||||||
|
channelsCancellable = nil
|
||||||
|
permissionCancellable = nil
|
||||||
|
settingCancellable = nil
|
||||||
|
managerCancellable = nil
|
||||||
|
releaseManager(manager)
|
||||||
|
manager = nil
|
||||||
|
noteCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retarget() {
|
||||||
|
guard activeHolders > 0,
|
||||||
|
revealed,
|
||||||
|
LocationNotesSettings.enabled,
|
||||||
|
locationManager.permissionState == .authorized,
|
||||||
|
let geohash = locationManager.availableChannels
|
||||||
|
.first(where: { $0.level == .building })?.geohash
|
||||||
|
else {
|
||||||
|
managerCancellable = nil
|
||||||
|
releaseManager(manager)
|
||||||
|
manager = nil
|
||||||
|
noteCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let manager {
|
||||||
|
guard manager.geohash != geohash.lowercased() else { return }
|
||||||
|
// Pooled managers are shared; never retarget one in place —
|
||||||
|
// release the old cell and acquire the new one.
|
||||||
|
managerCancellable = nil
|
||||||
|
releaseManager(manager)
|
||||||
|
self.manager = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fresh = managerFactory(geohash)
|
||||||
|
manager = fresh
|
||||||
|
managerCancellable = fresh.$notes
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] notes in
|
||||||
|
let now = Date()
|
||||||
|
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,6 @@ final class PeerIdentityStore: ObservableObject {
|
|||||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
|
||||||
stablePeerIDsByShortID = mappings
|
|
||||||
}
|
|
||||||
|
|
||||||
func fingerprint(for peerID: PeerID) -> String? {
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
peerFingerprintsByPeerID[peerID]
|
peerFingerprintsByPeerID[peerID]
|
||||||
}
|
}
|
||||||
@@ -94,10 +90,6 @@ final class PeerIdentityStore: ObservableObject {
|
|||||||
invalidateEncryptionCache(for: peerID)
|
invalidateEncryptionCache(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
|
||||||
encryptionStatuses = statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||||
verifiedFingerprints = fingerprints
|
verifiedFingerprints = fingerprints
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct FingerprintPresentationState: Equatable {
|
struct FingerprintPresentationState: Equatable {
|
||||||
let statusPeerID: PeerID
|
|
||||||
let peerNickname: String
|
let peerNickname: String
|
||||||
let encryptionStatus: EncryptionStatus
|
let encryptionStatus: EncryptionStatus
|
||||||
let theirFingerprint: String?
|
let theirFingerprint: String?
|
||||||
@@ -56,10 +55,6 @@ final class VerificationModel: ObservableObject {
|
|||||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
|
||||||
chatViewModel.beginQRVerification(with: qr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||||
return .invalid
|
return .invalid
|
||||||
@@ -110,7 +105,6 @@ final class VerificationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return FingerprintPresentationState(
|
return FingerprintPresentationState(
|
||||||
statusPeerID: statusPeerID,
|
|
||||||
peerNickname: peerNickname,
|
peerNickname: peerNickname,
|
||||||
encryptionStatus: encryptionStatus,
|
encryptionStatus: encryptionStatus,
|
||||||
theirFingerprint: theirFingerprint,
|
theirFingerprint: theirFingerprint,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 460 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 50 KiB |
@@ -27,6 +27,66 @@
|
|||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_16x16.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_16x16@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_32x32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_32x32@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_128x128.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_128x128@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_256x256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_256x256@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_512x512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_512x512@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -8,9 +8,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
#if DEBUG
|
|
||||||
import BitLogger
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
@@ -43,14 +40,10 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(runtime.locationChannelsModel)
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
.environmentObject(runtime.peerListModel)
|
.environmentObject(runtime.peerListModel)
|
||||||
.environmentObject(runtime.appChromeModel)
|
.environmentObject(runtime.appChromeModel)
|
||||||
|
.environmentObject(runtime.boardAlertsModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
runtime.start()
|
||||||
#if DEBUG
|
|
||||||
// Arm the opt-in UDP log sink from persisted config (no-op
|
|
||||||
// until a collector host is set in the App Info sheet).
|
|
||||||
LogNetworkSink.shared.reloadConfiguration()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
runtime.handleOpenURL(url)
|
runtime.handleOpenURL(url)
|
||||||
@@ -111,12 +104,20 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
|
let actionIdentifier = response.actionIdentifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
|
// Complete only after the response is handled: for a background
|
||||||
|
// action (👋 wave) the system may suspend the app the moment the
|
||||||
|
// completion handler runs, which would drop the queued send.
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
self.runtime?.handleNotificationResponse(
|
||||||
|
identifier: identifier,
|
||||||
|
actionIdentifier: actionIdentifier,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
completionHandler()
|
||||||
}
|
}
|
||||||
completionHandler()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
//
|
||||||
|
// AudioSessionCoordinator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The raw audio-session calls the coordinator makes, abstracted so the
|
||||||
|
/// state machine is unit-testable with a mock (and compiles on the macOS
|
||||||
|
/// test host, where `AVAudioSession` doesn't exist).
|
||||||
|
///
|
||||||
|
/// Calls arrive on the coordinator's private serial queue — never the main
|
||||||
|
/// thread. `setCategory`/`setActive` block on IPC to the audio server
|
||||||
|
/// (observed >1 s under contention on device, tripping the system gesture
|
||||||
|
/// gate), and Apple explicitly recommends activating the session off the
|
||||||
|
/// main thread.
|
||||||
|
protocol SessionApplying: Sendable {
|
||||||
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws
|
||||||
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sole owner of `AVAudioSession` category/activation for voice features.
|
||||||
|
///
|
||||||
|
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
|
||||||
|
/// voice notes) can be live simultaneously; letting each engine configure
|
||||||
|
/// the shared session directly made them stomp each other's category and
|
||||||
|
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
|
||||||
|
/// client acquires a `Token` and the coordinator:
|
||||||
|
///
|
||||||
|
/// - reference-counts activation: `setActive(true)` only on the first
|
||||||
|
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
|
||||||
|
/// last one releases — no client can deactivate another's session;
|
||||||
|
/// - keeps one escalating category: playback-only holders get `.playback`,
|
||||||
|
/// any capture holder escalates to `.playAndRecord`, and the category is
|
||||||
|
/// never downgraded while anyone still holds a token (capture ending must
|
||||||
|
/// not yank the route out from under live playback);
|
||||||
|
/// - fans out `onInterrupted` on system interruptions and when the active
|
||||||
|
/// route's device disappears (no auto-resume: bursts are transient, the
|
||||||
|
/// next press or burst simply re-acquires). The escalating category change
|
||||||
|
/// fans out separately as `onCategoryEscalated` — the session stays live,
|
||||||
|
/// so holders that can rebuild their engine against the new configuration
|
||||||
|
/// keep playing (talk-over is bidirectional); holders that don't provide
|
||||||
|
/// it fall back to `onInterrupted`.
|
||||||
|
///
|
||||||
|
/// Threading: all state lives on a private serial queue, which both
|
||||||
|
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
|
||||||
|
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
|
||||||
|
/// is fire-and-forget onto the queue). Holder callbacks always run on the
|
||||||
|
/// main actor.
|
||||||
|
///
|
||||||
|
/// Microphone *permission* queries stay with their callers; this type owns
|
||||||
|
/// only category and activation.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
|
||||||
|
final class AudioSessionCoordinator: @unchecked Sendable {
|
||||||
|
enum Use {
|
||||||
|
case playback
|
||||||
|
case capture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session category the coordinator has applied (the `SessionApplying`
|
||||||
|
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
|
||||||
|
enum Category {
|
||||||
|
case playback
|
||||||
|
case playAndRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque handle for one client's hold on the session. Release exactly
|
||||||
|
/// once when done (extra releases are ignored).
|
||||||
|
///
|
||||||
|
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
|
||||||
|
/// closures (non-Sendable as stored types). Lifecycle state is protected
|
||||||
|
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
|
||||||
|
final class Token: @unchecked Sendable {
|
||||||
|
fileprivate enum CallbackKind: Sendable {
|
||||||
|
case interrupted
|
||||||
|
case categoryEscalated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A callback snapshot is only valid for the lifecycle epoch in which
|
||||||
|
/// it was captured. `release` advances the epoch synchronously before
|
||||||
|
/// its queue work, so a callback already headed to the main actor can't
|
||||||
|
/// reach a client that has since released this token and reacquired a
|
||||||
|
/// different one.
|
||||||
|
fileprivate struct CallbackTicket: Sendable {
|
||||||
|
let token: Token
|
||||||
|
let kind: CallbackKind
|
||||||
|
let lifecycleEpoch: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Lifecycle {
|
||||||
|
/// Registered on the session queue, but `acquire` has not yet
|
||||||
|
/// returned into the client's main-actor call frame.
|
||||||
|
case acquiring
|
||||||
|
case ready
|
||||||
|
case released
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate let onInterrupted: @MainActor () -> Void
|
||||||
|
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
|
||||||
|
private let stateLock = NSLock()
|
||||||
|
private var lifecycle = Lifecycle.acquiring
|
||||||
|
private var lifecycleEpoch: UInt64 = 0
|
||||||
|
/// A terminal event that lands while the token is registered but not
|
||||||
|
/// yet handed off invalidates the acquire before its caller can start.
|
||||||
|
private var terminalEventPendingHandoff = false
|
||||||
|
|
||||||
|
fileprivate init(
|
||||||
|
onInterrupted: @escaping @MainActor () -> Void,
|
||||||
|
onCategoryEscalated: (@MainActor () -> Void)?
|
||||||
|
) {
|
||||||
|
self.onInterrupted = onInterrupted
|
||||||
|
self.onCategoryEscalated = onCategoryEscalated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an event at the same linearization point at which the
|
||||||
|
/// coordinator snapshots its holders. An acquiring token cannot safely
|
||||||
|
/// receive a callback yet: terminal events invalidate the acquire,
|
||||||
|
/// while category escalation needs no callback because its engine will
|
||||||
|
/// start against the already-escalated configuration.
|
||||||
|
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
|
||||||
|
stateLock.withLock {
|
||||||
|
switch lifecycle {
|
||||||
|
case .acquiring:
|
||||||
|
switch kind {
|
||||||
|
case .interrupted:
|
||||||
|
terminalEventPendingHandoff = true
|
||||||
|
case .categoryEscalated:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case .ready:
|
||||||
|
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
|
||||||
|
case .released:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the main-actor ownership handoff if no terminal event
|
||||||
|
/// invalidated it. Because `acquire` itself is main-actor isolated, a
|
||||||
|
/// successful handoff returns directly into the caller without another
|
||||||
|
/// actor hop; no callback can interleave before the caller stores the
|
||||||
|
/// returned token.
|
||||||
|
fileprivate func completeHandoff() -> Bool {
|
||||||
|
stateLock.withLock {
|
||||||
|
guard lifecycle == .acquiring,
|
||||||
|
!terminalEventPendingHandoff
|
||||||
|
else { return false }
|
||||||
|
lifecycle = .ready
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the token dead synchronously, before the asynchronous holder
|
||||||
|
/// removal. Returns false for an already-released token.
|
||||||
|
fileprivate func markReleased() -> Bool {
|
||||||
|
stateLock.withLock {
|
||||||
|
guard lifecycle != .released else { return false }
|
||||||
|
lifecycle = .released
|
||||||
|
lifecycleEpoch &+= 1
|
||||||
|
terminalEventPendingHandoff = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revalidates a queue snapshot at the main-actor delivery boundary.
|
||||||
|
/// The lock is deliberately released before invoking client code: real
|
||||||
|
/// callbacks commonly call `release` on this same token.
|
||||||
|
@MainActor
|
||||||
|
fileprivate func deliver(_ ticket: CallbackTicket) {
|
||||||
|
let isLive = stateLock.withLock {
|
||||||
|
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
|
||||||
|
}
|
||||||
|
guard isLive else { return }
|
||||||
|
switch ticket.kind {
|
||||||
|
case .interrupted:
|
||||||
|
onInterrupted()
|
||||||
|
case .categoryEscalated:
|
||||||
|
(onCategoryEscalated ?? onInterrupted)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic suspension points for lifecycle race tests. Production
|
||||||
|
/// instances use the nil defaults; the hooks never move session calls off
|
||||||
|
/// the coordinator queue or callback execution off the main actor.
|
||||||
|
struct TestingHooks: Sendable {
|
||||||
|
let beforeAcquireHandoff: (@Sendable () async -> Void)?
|
||||||
|
let beforeCallbackDelivery: (@Sendable () async -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
|
||||||
|
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.beforeAcquireHandoff = beforeAcquireHandoff
|
||||||
|
self.beforeCallbackDelivery = beforeCallbackDelivery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
|
||||||
|
|
||||||
|
private let session: SessionApplying
|
||||||
|
private let testingHooks: TestingHooks
|
||||||
|
/// Confines all mutable state, serializes whole acquire/release
|
||||||
|
/// operations (two rapid presses can't interleave their category and
|
||||||
|
/// activation calls), and hosts the blocking session IPC off main.
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
|
||||||
|
|
||||||
|
// Queue-confined state.
|
||||||
|
private var holders: [ObjectIdentifier: Token] = [:]
|
||||||
|
private var currentCategory: Category?
|
||||||
|
private var sessionActive = false
|
||||||
|
/// Written once in init, read in deinit — never touched concurrently.
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
|
||||||
|
self.session = session
|
||||||
|
self.testingHooks = testingHooks
|
||||||
|
observeSystemNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
for observer in observers {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures + activates the session for `use` and registers the caller
|
||||||
|
/// as a holder. The blocking `AVAudioSession` calls run on the session
|
||||||
|
/// queue — the caller suspends instead of stalling its thread (a PTT
|
||||||
|
/// press used to block main >1 s in `setActive`, tripping the system
|
||||||
|
/// gesture gate). `onInterrupted` fires (on the main actor) when the
|
||||||
|
/// client must stop using the session: a system interruption began or
|
||||||
|
/// its route's device went away. The client should stop its engine,
|
||||||
|
/// finalize any artifacts, and release — resuming means acquiring again.
|
||||||
|
///
|
||||||
|
/// `onCategoryEscalated` fires instead when the session category
|
||||||
|
/// escalated underneath the holder (a capture client joined): the session
|
||||||
|
/// stays active, so a holder that can rebuild its engine against the new
|
||||||
|
/// configuration should restart and keep going. Holders that pass `nil`
|
||||||
|
/// get `onInterrupted` for escalation too. Escalation is delivered before
|
||||||
|
/// `acquire` returns, so the new holder starts its engine strictly after
|
||||||
|
/// existing ones were told to rebuild. Main-actor isolation is also the
|
||||||
|
/// ownership handoff boundary: if interruption or route loss lands after
|
||||||
|
/// queue registration but before that boundary, the provisional holder is
|
||||||
|
/// removed and `acquire` throws `CancellationError` instead of returning a
|
||||||
|
/// token whose callback already fired.
|
||||||
|
@MainActor
|
||||||
|
func acquire(
|
||||||
|
_ use: Use,
|
||||||
|
onInterrupted: @escaping @MainActor () -> Void,
|
||||||
|
onCategoryEscalated: (@MainActor () -> Void)? = nil
|
||||||
|
) async throws -> Token {
|
||||||
|
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
|
||||||
|
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
|
||||||
|
queue.async {
|
||||||
|
do {
|
||||||
|
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalating playback -> playAndRecord reconfigures the hardware
|
||||||
|
// route; engines started against the old configuration must restart.
|
||||||
|
if !reconfigured.isEmpty {
|
||||||
|
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
|
||||||
|
await deliver(reconfigured)
|
||||||
|
}
|
||||||
|
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
|
||||||
|
await beforeAcquireHandoff()
|
||||||
|
}
|
||||||
|
guard token.completeHandoff() else {
|
||||||
|
// A call/Siri interruption or route loss landed after registration
|
||||||
|
// but before ownership handoff. Remove the provisional holder and
|
||||||
|
// fail instead of starting a client engine after the stop event.
|
||||||
|
release(token)
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops one holder. Deactivates the session (notifying other apps) only
|
||||||
|
/// when the last holder releases. Safe to call more than once, from any
|
||||||
|
/// thread (including `deinit` paths): the work is fire-and-forget onto
|
||||||
|
/// the session queue, so the blocking deactivation IPC never runs on the
|
||||||
|
/// caller.
|
||||||
|
func release(_ token: Token) {
|
||||||
|
guard token.markReleased() else { return }
|
||||||
|
queue.async {
|
||||||
|
self.releaseOnQueue(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queue-confined core
|
||||||
|
|
||||||
|
/// Returns callback tickets for pre-existing live holders whose engines
|
||||||
|
/// must restart because this acquire escalated the category.
|
||||||
|
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
|
||||||
|
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
|
||||||
|
let categoryChanged = target != currentCategory
|
||||||
|
let previousCategory = currentCategory
|
||||||
|
if categoryChanged {
|
||||||
|
try session.setCategory(target)
|
||||||
|
currentCategory = target
|
||||||
|
}
|
||||||
|
if !sessionActive {
|
||||||
|
do {
|
||||||
|
try session.setActive(true, notifyOthersOnDeactivation: false)
|
||||||
|
} catch {
|
||||||
|
// Activation failed (e.g. a phone call owns the hardware):
|
||||||
|
// with no holder registered, an escalated category recorded
|
||||||
|
// here would stick and pin later playback-only acquires to
|
||||||
|
// .playAndRecord. Existing holders keep the category the
|
||||||
|
// hardware really has.
|
||||||
|
if categoryChanged, holders.isEmpty {
|
||||||
|
currentCategory = previousCategory
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
sessionActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let reconfigured = categoryChanged
|
||||||
|
? holders.values.compactMap { $0.record(.categoryEscalated) }
|
||||||
|
: []
|
||||||
|
holders[ObjectIdentifier(token)] = token
|
||||||
|
return reconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseOnQueue(_ token: Token) {
|
||||||
|
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
|
||||||
|
guard holders.isEmpty else { return }
|
||||||
|
currentCategory = nil
|
||||||
|
guard sessionActive else { return }
|
||||||
|
sessionActive = false
|
||||||
|
do {
|
||||||
|
try session.setActive(false, notifyOthersOnDeactivation: true)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
queue.async {
|
||||||
|
continuation.resume(returning: body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func deliver(_ tickets: [Token.CallbackTicket]) async {
|
||||||
|
guard !tickets.isEmpty else { return }
|
||||||
|
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
|
||||||
|
await beforeCallbackDelivery()
|
||||||
|
}
|
||||||
|
for ticket in tickets {
|
||||||
|
ticket.token.deliver(ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System events (internal so tests can drive them directly)
|
||||||
|
|
||||||
|
/// A system interruption began: the session is already deactivated by the
|
||||||
|
/// OS, so just mark it inactive and tell every ready holder (on the main
|
||||||
|
/// actor) to stop. A provisional acquiring holder is invalidated instead.
|
||||||
|
/// No auto-resume — the next acquire re-activates.
|
||||||
|
func handleInterruptionBegan() async {
|
||||||
|
let tickets = await onQueue { () -> [Token.CallbackTicket] in
|
||||||
|
self.sessionActive = false
|
||||||
|
return self.holders.values.compactMap { $0.record(.interrupted) }
|
||||||
|
}
|
||||||
|
await deliver(tickets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active route's input/output device disappeared (e.g. BT headset
|
||||||
|
/// off): ready holders' engines are wedged against a dead route — stop
|
||||||
|
/// them; invalidate a holder whose acquire has not returned yet.
|
||||||
|
func handleRouteDeviceUnavailable() async {
|
||||||
|
let tickets = await onQueue {
|
||||||
|
self.holders.values.compactMap { $0.record(.interrupted) }
|
||||||
|
}
|
||||||
|
await deliver(tickets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test hook: suspends until every session operation enqueued before this
|
||||||
|
/// call — including fire-and-forget `release`s — has completed.
|
||||||
|
func drain() async {
|
||||||
|
await onQueue {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeSystemNotifications() {
|
||||||
|
#if os(iOS)
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
observers.append(center.addObserver(
|
||||||
|
forName: AVAudioSession.interruptionNotification,
|
||||||
|
object: AVAudioSession.sharedInstance(),
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
AVAudioSession.InterruptionType(rawValue: raw) == .began,
|
||||||
|
let self
|
||||||
|
else { return }
|
||||||
|
SecureLogger.info("AudioSession: interruption began", category: .session)
|
||||||
|
Task { await self.handleInterruptionBegan() }
|
||||||
|
})
|
||||||
|
observers.append(center.addObserver(
|
||||||
|
forName: AVAudioSession.routeChangeNotification,
|
||||||
|
object: AVAudioSession.sharedInstance(),
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
|
||||||
|
let self
|
||||||
|
else { return }
|
||||||
|
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
|
||||||
|
Task { await self.handleRouteDeviceUnavailable() }
|
||||||
|
})
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Production adapter
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private struct SystemAudioSession: SessionApplying {
|
||||||
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
switch category {
|
||||||
|
case .playback:
|
||||||
|
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||||
|
case .playAndRecord:
|
||||||
|
// allowBluetoothHFP is not available on iOS Simulator
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(
|
||||||
|
active,
|
||||||
|
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
/// macOS has no app-level audio session; the coordinator still runs its
|
||||||
|
/// bookkeeping so client code is identical across platforms.
|
||||||
|
private struct SystemAudioSession: SessionApplying {
|
||||||
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||||
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// PTTAudioCodec.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
|
||||||
|
/// carries a bit reservoir across frames); one instance per burst.
|
||||||
|
/// Not thread-safe — confine to one queue.
|
||||||
|
final class PTTFrameEncoder {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private var pendingInput: [AVAudioPCMBuffer] = []
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let aac = PTTAudioFormat.aacFormat,
|
||||||
|
let converter = AVAudioConverter(from: pcm, to: aac)
|
||||||
|
else { return nil }
|
||||||
|
converter.bitRate = PTTAudioFormat.bitRate
|
||||||
|
self.converter = converter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
|
||||||
|
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
|
||||||
|
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
|
||||||
|
pendingInput.append(buffer)
|
||||||
|
return drainConverter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainConverter() -> [Data] {
|
||||||
|
var frames: [Data] = []
|
||||||
|
while true {
|
||||||
|
let output = AVAudioCompressedBuffer(
|
||||||
|
format: converter.outputFormat,
|
||||||
|
packetCapacity: 8,
|
||||||
|
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
|
||||||
|
)
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
|
||||||
|
guard let self, let next = self.pendingInput.first else {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.pendingInput.removeFirst()
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
if status == .error {
|
||||||
|
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
frames.append(contentsOf: Self.extractPackets(from: output))
|
||||||
|
// .haveData means the output buffer filled and more may be ready;
|
||||||
|
// anything else means the converter wants more input.
|
||||||
|
if status != .haveData { return frames }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
|
||||||
|
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
|
||||||
|
var frames: [Data] = []
|
||||||
|
frames.reserveCapacity(Int(buffer.packetCount))
|
||||||
|
for index in 0..<Int(buffer.packetCount) {
|
||||||
|
let description = descriptions[index]
|
||||||
|
guard description.mDataByteSize > 0 else { continue }
|
||||||
|
let start = buffer.data.advanced(by: Int(description.mStartOffset))
|
||||||
|
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
|
||||||
|
/// inbound burst. Not thread-safe — confine to one queue/actor.
|
||||||
|
final class PTTFrameDecoder {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private let pcmFormat: AVAudioFormat
|
||||||
|
private let aacFormat: AVAudioFormat
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let aac = PTTAudioFormat.aacFormat,
|
||||||
|
let converter = AVAudioConverter(from: aac, to: pcm)
|
||||||
|
else { return nil }
|
||||||
|
self.converter = converter
|
||||||
|
self.pcmFormat = pcm
|
||||||
|
self.aacFormat = aac
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
|
||||||
|
/// while the decoder is still priming (the first frame of a stream).
|
||||||
|
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
|
||||||
|
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
|
||||||
|
|
||||||
|
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
|
||||||
|
frame.withUnsafeBytes { raw in
|
||||||
|
guard let base = raw.baseAddress else { return }
|
||||||
|
input.data.copyMemory(from: base, byteCount: frame.count)
|
||||||
|
}
|
||||||
|
input.byteLength = UInt32(frame.count)
|
||||||
|
input.packetCount = 1
|
||||||
|
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
||||||
|
mStartOffset: 0,
|
||||||
|
mVariableFramesInPacket: 0,
|
||||||
|
mDataByteSize: UInt32(frame.count)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let output = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: pcmFormat,
|
||||||
|
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
var consumed = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if consumed {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
guard status != .error else {
|
||||||
|
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output.frameLength > 0 ? output : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample-rate/channel converter from the microphone's native format to the
|
||||||
|
/// 16 kHz mono processing format. Stateful; not thread-safe.
|
||||||
|
final class PTTInputResampler {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private let outputFormat: AVAudioFormat
|
||||||
|
private let ratio: Double
|
||||||
|
|
||||||
|
init?(inputFormat: AVAudioFormat) {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let converter = AVAudioConverter(from: inputFormat, to: pcm)
|
||||||
|
else { return nil }
|
||||||
|
self.converter = converter
|
||||||
|
self.outputFormat = pcm
|
||||||
|
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||||
|
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
||||||
|
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
|
||||||
|
|
||||||
|
var consumed = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if consumed {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
guard status != .error else {
|
||||||
|
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output.frameLength > 0 ? output : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// PTTAudioFormat.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
|
||||||
|
/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings
|
||||||
|
/// so a burst's finalized `.m4a` and its live frames sound the same.
|
||||||
|
enum PTTAudioFormat {
|
||||||
|
static let sampleRate: Double = 16_000
|
||||||
|
static let channelCount: AVAudioChannelCount = 1
|
||||||
|
static let bitRate = 16_000
|
||||||
|
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
|
||||||
|
static let samplesPerFrame: AVAudioFrameCount = 1024
|
||||||
|
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
|
||||||
|
|
||||||
|
/// Uncompressed processing format (deinterleaved float PCM).
|
||||||
|
static var pcmFormat: AVAudioFormat? {
|
||||||
|
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compressed wire format.
|
||||||
|
static var aacFormat: AVAudioFormat? {
|
||||||
|
var description = AudioStreamBasicDescription(
|
||||||
|
mSampleRate: sampleRate,
|
||||||
|
mFormatID: kAudioFormatMPEG4AAC,
|
||||||
|
mFormatFlags: 0,
|
||||||
|
mBytesPerPacket: 0,
|
||||||
|
mFramesPerPacket: samplesPerFrame,
|
||||||
|
mBytesPerFrame: 0,
|
||||||
|
mChannelsPerFrame: channelCount,
|
||||||
|
mBitsPerChannel: 0,
|
||||||
|
mReserved: 0
|
||||||
|
)
|
||||||
|
return AVAudioFormat(streamDescription: &description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice-note container settings for the finalized `.m4a`, mirroring
|
||||||
|
/// `VoiceRecorder.startRecording()`.
|
||||||
|
static var voiceNoteFileSettings: [String: Any] {
|
||||||
|
[
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: sampleRate,
|
||||||
|
AVNumberOfChannelsKey: Int(channelCount),
|
||||||
|
AVEncoderBitRateKey: bitRate
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
|
||||||
|
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
|
||||||
|
/// stream is playable at any prefix — a partially received burst is still a
|
||||||
|
/// replayable voice note.
|
||||||
|
enum ADTSFramer {
|
||||||
|
private static let headerSize = 7
|
||||||
|
/// MPEG-4 sampling frequency index for 16 kHz.
|
||||||
|
private static let samplingFrequencyIndex: UInt8 = 8
|
||||||
|
private static let channelConfiguration: UInt8 = 1
|
||||||
|
|
||||||
|
/// Wraps one raw AAC-LC frame in an ADTS header.
|
||||||
|
static func frame(_ aacFrame: Data) -> Data {
|
||||||
|
let frameLength = aacFrame.count + headerSize
|
||||||
|
var data = Data(capacity: frameLength)
|
||||||
|
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
|
||||||
|
data.append(0xFF)
|
||||||
|
data.append(0xF1)
|
||||||
|
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
|
||||||
|
// private bit 0, channel config high bit.
|
||||||
|
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
|
||||||
|
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
|
||||||
|
data.append(UInt8((frameLength >> 3) & 0xFF))
|
||||||
|
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
|
||||||
|
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
|
||||||
|
data.append(0xFC)
|
||||||
|
data.append(aacFrame)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
//
|
||||||
|
// PTTBurstPlayer.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
@preconcurrency import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The engine operations behind live-burst playback, abstracted so the
|
||||||
|
/// player's lifecycle (jitter start, category-escalation restart, stop) is
|
||||||
|
/// unit-testable without real audio hardware.
|
||||||
|
@MainActor
|
||||||
|
protocol PTTPlaybackEngine: AnyObject {
|
||||||
|
/// The object `AVAudioEngineConfigurationChange` notifications are posted
|
||||||
|
/// for (nil for mocks — no observer is registered).
|
||||||
|
var configChangeObject: AnyObject? { get }
|
||||||
|
func start() throws
|
||||||
|
func play()
|
||||||
|
func stop()
|
||||||
|
func schedule(
|
||||||
|
_ buffer: AVAudioPCMBuffer,
|
||||||
|
completionType: PTTPlaybackCompletionType,
|
||||||
|
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
|
||||||
|
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
|
||||||
|
/// may arrive before the render pipeline has made the audio audible.
|
||||||
|
enum PTTPlaybackCompletionType: Equatable, Sendable {
|
||||||
|
case dataConsumed
|
||||||
|
case dataPlayedBack
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
|
||||||
|
case dataConsumed
|
||||||
|
case dataPlayedBack
|
||||||
|
/// AVAudioPlayerNode invokes the requested callback when the node is
|
||||||
|
/// stopped too. That is not audible completion and must remain replayable.
|
||||||
|
case playbackStopped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
|
||||||
|
/// an engine instantiated against an earlier audio-session configuration keeps
|
||||||
|
/// rendering to the stale route (same class of failure as the capture side's
|
||||||
|
/// fresh-engine-per-press rule).
|
||||||
|
@MainActor
|
||||||
|
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private let node = AVAudioPlayerNode()
|
||||||
|
|
||||||
|
init(format: AVAudioFormat) {
|
||||||
|
engine.attach(node)
|
||||||
|
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configChangeObject: AnyObject? { engine }
|
||||||
|
|
||||||
|
func start() throws {
|
||||||
|
engine.prepare()
|
||||||
|
try engine.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
node.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
node.stop()
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func schedule(
|
||||||
|
_ buffer: AVAudioPCMBuffer,
|
||||||
|
completionType: PTTPlaybackCompletionType,
|
||||||
|
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
|
||||||
|
) {
|
||||||
|
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
|
||||||
|
case .dataConsumed: .dataConsumed
|
||||||
|
case .dataPlayedBack: .dataPlayedBack
|
||||||
|
}
|
||||||
|
let scheduledEngine = engine
|
||||||
|
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
|
||||||
|
// The API invokes this callback when the player is stopped as
|
||||||
|
// well. A configuration change can stop the engine before its
|
||||||
|
// notification reaches MainActor, so do not misclassify that
|
||||||
|
// flushed tail as audible playback.
|
||||||
|
guard scheduledEngine?.isRunning == true else {
|
||||||
|
completionHandler(.playbackStopped)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch callbackType {
|
||||||
|
case .dataConsumed:
|
||||||
|
completionHandler(.dataConsumed)
|
||||||
|
case .dataRendered:
|
||||||
|
completionHandler(.dataConsumed)
|
||||||
|
case .dataPlayedBack:
|
||||||
|
completionHandler(.dataPlayedBack)
|
||||||
|
@unknown default:
|
||||||
|
completionHandler(.playbackStopped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completion callbacks arrive off the main actor, while engine rebuilds are
|
||||||
|
/// serialized on it. This small lock-backed latch lets a rebuild atomically
|
||||||
|
/// claim only buffers whose completion has not already fired — even when the
|
||||||
|
/// callback's hop back to the main actor is still queued.
|
||||||
|
private final class PTTPlaybackCompletionState: @unchecked Sendable {
|
||||||
|
private enum State {
|
||||||
|
case scheduled
|
||||||
|
case completed
|
||||||
|
case retired
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var state: State = .scheduled
|
||||||
|
|
||||||
|
/// Returns true exactly once when playback completion wins the race with
|
||||||
|
/// an engine rebuild or stop.
|
||||||
|
func complete() -> Bool {
|
||||||
|
lock.withLock {
|
||||||
|
guard case .scheduled = state else { return false }
|
||||||
|
state = .completed
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true exactly once when a rebuild or stop claims this
|
||||||
|
/// still-pending schedule. Later callbacks from that engine are stale.
|
||||||
|
func retireIfPending() -> Bool {
|
||||||
|
lock.withLock {
|
||||||
|
guard case .scheduled = state else { return false }
|
||||||
|
state = .retired
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays one inbound live voice burst with a small jitter buffer.
|
||||||
|
///
|
||||||
|
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
||||||
|
/// an underrun (missing/late packets) simply pauses output until the next
|
||||||
|
/// buffer arrives, which self-heals timing without explicit silence
|
||||||
|
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
||||||
|
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
||||||
|
///
|
||||||
|
/// Talk-over is bidirectional: when push-to-talk capture starts while this
|
||||||
|
/// burst plays, the session category escalates underneath the engine — the
|
||||||
|
/// player rebuilds a fresh engine against the new configuration and keeps
|
||||||
|
/// streaming instead of dying. Real interruptions (phone call, route device
|
||||||
|
/// gone) still stop it; the burst keeps assembling to file either way.
|
||||||
|
@MainActor
|
||||||
|
final class PTTBurstPlayer {
|
||||||
|
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
|
||||||
|
/// handful of category/route changes is plenty — beyond it something is
|
||||||
|
/// thrashing and stopping cleanly beats an engine-rebuild loop.
|
||||||
|
private static let maxEngineRestarts = 8
|
||||||
|
|
||||||
|
private let makeEngine: @MainActor () -> PTTPlaybackEngine
|
||||||
|
private var engine: PTTPlaybackEngine
|
||||||
|
private let decoder: PTTFrameDecoder
|
||||||
|
private let coordinator: AudioSessionCoordinator
|
||||||
|
/// Injectable so tests don't fight over the app-wide exclusive-playback
|
||||||
|
/// slot (a parallel test's `play()` would stop this player mid-test).
|
||||||
|
private let exclusivity: VoiceNotePlaybackCoordinator
|
||||||
|
|
||||||
|
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
||||||
|
private var queuedDuration: TimeInterval = 0
|
||||||
|
private struct ScheduledBuffer {
|
||||||
|
let id: UInt64
|
||||||
|
let buffer: AVAudioPCMBuffer
|
||||||
|
let completionState: PTTPlaybackCompletionState
|
||||||
|
}
|
||||||
|
/// Buffers handed to the current engine whose completion has not yet
|
||||||
|
/// been processed on the main actor. Keeping the buffers themselves lets
|
||||||
|
/// a category-escalation rebuild replay the unfinished tail in order.
|
||||||
|
private var scheduledBuffers: [ScheduledBuffer] = []
|
||||||
|
private var nextScheduledBufferID: UInt64 = 0
|
||||||
|
/// Bumped on every engine rebuild or stop so completion tasks from a
|
||||||
|
/// torn-down engine cannot mutate the current generation's pending list.
|
||||||
|
private var engineGeneration = 0
|
||||||
|
private var engineRestarts = 0
|
||||||
|
private var engineStarted = false
|
||||||
|
private var finished = false
|
||||||
|
/// Latched off (internal read so tests can await the async failure path).
|
||||||
|
private(set) var stopped = false
|
||||||
|
/// A session acquire is in flight (it suspends off-main for the blocking
|
||||||
|
/// session IPC); gates `startIfReady` against double acquisition.
|
||||||
|
private var acquiringSession = false
|
||||||
|
private var deadlineTask: Task<Void, Never>?
|
||||||
|
private var sessionToken: AudioSessionCoordinator.Token?
|
||||||
|
/// Reserved before the session acquire suspends. Activation succeeds only
|
||||||
|
/// if no newer playback request claimed the floor in the meantime.
|
||||||
|
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
|
||||||
|
private var configChangeObserver: NSObjectProtocol?
|
||||||
|
|
||||||
|
private(set) var isPlaying = false
|
||||||
|
|
||||||
|
/// Fires exactly once when the player stops for good (drain-out, cancel,
|
||||||
|
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
|
||||||
|
/// the draining player it keeps alive after the assembly — the player's
|
||||||
|
/// only long-lived owner — is discarded on burst END.
|
||||||
|
var onStopped: (() -> Void)?
|
||||||
|
|
||||||
|
init?(
|
||||||
|
coordinator: AudioSessionCoordinator? = nil,
|
||||||
|
exclusivity: VoiceNotePlaybackCoordinator? = nil,
|
||||||
|
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
|
||||||
|
) {
|
||||||
|
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||||
|
self.decoder = decoder
|
||||||
|
self.coordinator = coordinator ?? .shared
|
||||||
|
self.exclusivity = exclusivity ?? .shared
|
||||||
|
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
|
||||||
|
self.makeEngine = factory
|
||||||
|
self.engine = factory()
|
||||||
|
|
||||||
|
deadlineTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
||||||
|
self?.startIfReady(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Backstop for an owner dropping the player before it stopped: the
|
||||||
|
// session coordinator retains registered tokens strongly, so a token
|
||||||
|
// leaked here would keep the session active (and pin any escalated
|
||||||
|
// category) for the app's lifetime. `release` is fire-and-forget
|
||||||
|
// onto the coordinator's queue, so it is deinit-safe.
|
||||||
|
if let token = sessionToken {
|
||||||
|
coordinator.release(token)
|
||||||
|
}
|
||||||
|
if let observer = configChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
|
deadlineTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes and queues frames (in burst order). Starts playback when the
|
||||||
|
/// jitter buffer fills.
|
||||||
|
func enqueue(_ frames: [Data]) {
|
||||||
|
guard !stopped else { return }
|
||||||
|
for frame in frames {
|
||||||
|
guard let pcm = decoder.decode(frame) else { continue }
|
||||||
|
if engineStarted {
|
||||||
|
schedule(pcm)
|
||||||
|
} else {
|
||||||
|
queuedBuffers.append(pcm)
|
||||||
|
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startIfReady(force: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The burst ended: stop once everything scheduled has played out.
|
||||||
|
func finishAfterDrain() {
|
||||||
|
finished = true
|
||||||
|
// The complete burst is queued — no jitter left to wait for. This
|
||||||
|
// also matters when END lands while the async session acquire is
|
||||||
|
// still in flight: the queued audio must play out, not be treated
|
||||||
|
// as already drained.
|
||||||
|
startIfReady(force: true)
|
||||||
|
stopIfDrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immediate stop (cancel, another playback taking over, interruption,
|
||||||
|
/// teardown).
|
||||||
|
func stop() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
stopped = true
|
||||||
|
deadlineTask?.cancel()
|
||||||
|
removeConfigObserver()
|
||||||
|
queuedBuffers = []
|
||||||
|
retireScheduledBuffers()
|
||||||
|
if engineStarted {
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
isPlaying = false
|
||||||
|
releaseSessionToken()
|
||||||
|
exclusivity.deactivate(self)
|
||||||
|
onStopped?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startIfReady(force: Bool) {
|
||||||
|
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
|
||||||
|
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||||
|
|
||||||
|
// Acquiring the session suspends for its blocking IPC (off the main
|
||||||
|
// actor); frames arriving meanwhile keep queueing and are flushed
|
||||||
|
// onto the engine once it starts.
|
||||||
|
acquiringSession = true
|
||||||
|
playbackReservation = exclusivity.reserve(self)
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.acquireSessionAndStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquireSessionAndStart() async {
|
||||||
|
let token: AudioSessionCoordinator.Token
|
||||||
|
do {
|
||||||
|
token = try await coordinator.acquire(
|
||||||
|
.playback,
|
||||||
|
onInterrupted: { [weak self] in self?.stop() },
|
||||||
|
onCategoryEscalated: { [weak self] in self?.restartEngine() }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
acquiringSession = false
|
||||||
|
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||||
|
// Playing unregistered would leave the engine exposed: another
|
||||||
|
// holder's last release deactivates the session mid-play, and no
|
||||||
|
// interruption/escalation fan-out ever reaches us. Bail like the
|
||||||
|
// engine-start failure below; the burst still assembles to file.
|
||||||
|
// (stop() also fires onStopped so a parked draining player is
|
||||||
|
// unparked instead of leaking.)
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acquiringSession = false
|
||||||
|
// stop() (cancel, exclusivity, teardown) may have landed while the
|
||||||
|
// session was activating: hand the token straight back.
|
||||||
|
guard !stopped else {
|
||||||
|
coordinator.release(token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionToken = token
|
||||||
|
guard let playbackReservation,
|
||||||
|
exclusivity.isCurrent(playbackReservation, for: self)
|
||||||
|
else {
|
||||||
|
// The request was superseded while audio-session activation was
|
||||||
|
// suspended. Do not even start the retired engine.
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe reconfiguration before starting so nothing lands between.
|
||||||
|
registerConfigObserver()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
// A capture racing this start can reconfigure the session while
|
||||||
|
// the engine spins up (its escalation fan-out no-ops on a player
|
||||||
|
// that never started): rebuild once against the settled
|
||||||
|
// configuration — counted against the restart cap — before
|
||||||
|
// giving up.
|
||||||
|
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
|
||||||
|
removeConfigObserver()
|
||||||
|
engineRestarts += 1
|
||||||
|
engine = makeEngine()
|
||||||
|
registerConfigObserver()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||||
|
// stop() removes the observer, hands the token back, and
|
||||||
|
// fires onStopped for any parked draining owner.
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engineStarted = true
|
||||||
|
guard exclusivity.activate(self, reservation: playbackReservation)
|
||||||
|
else {
|
||||||
|
// A newer user-initiated playback reserved the floor while this
|
||||||
|
// older PTT request was suspended in audio-session activation.
|
||||||
|
// Never let the late completion steal playback back.
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isPlaying = true
|
||||||
|
engine.play()
|
||||||
|
|
||||||
|
let buffered = queuedBuffers
|
||||||
|
queuedBuffers = []
|
||||||
|
queuedDuration = 0
|
||||||
|
for buffer in buffered {
|
||||||
|
schedule(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio session was reconfigured underneath the running engine
|
||||||
|
/// (category escalation for talk-over, or an engine configuration
|
||||||
|
/// change): rebuild a fresh engine against the new configuration and
|
||||||
|
/// keep streaming. Buffers already completed stay completed; the
|
||||||
|
/// unfinished scheduled tail is replayed in order on the fresh engine,
|
||||||
|
/// and frames still arriving continue scheduling after it.
|
||||||
|
private func restartEngine() {
|
||||||
|
guard engineStarted, !stopped else { return }
|
||||||
|
engineRestarts += 1
|
||||||
|
guard engineRestarts <= Self.maxEngineRestarts else {
|
||||||
|
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removeConfigObserver()
|
||||||
|
// Claim the unfinished tail before stopping the old engine. Stopping
|
||||||
|
// a player node may itself invoke its completion handlers; retiring
|
||||||
|
// the claimed entries first makes those callbacks unambiguously stale.
|
||||||
|
// A completion that fired just before this rebuild wins the latch and
|
||||||
|
// is excluded even if its MainActor task has not run yet.
|
||||||
|
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
|
||||||
|
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
|
||||||
|
}
|
||||||
|
scheduledBuffers = []
|
||||||
|
engineGeneration += 1
|
||||||
|
engine.stop()
|
||||||
|
engine = makeEngine()
|
||||||
|
registerConfigObserver()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engine.play()
|
||||||
|
for buffer in buffersToReplay {
|
||||||
|
schedule(buffer)
|
||||||
|
}
|
||||||
|
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
|
||||||
|
// If every old buffer completed before the rebuild, a finished burst
|
||||||
|
// can stop now. Otherwise the replayed tail keeps it alive until its
|
||||||
|
// new-generation completions arrive.
|
||||||
|
stopIfDrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerConfigObserver() {
|
||||||
|
guard let object = engine.configChangeObject else { return }
|
||||||
|
configChangeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVAudioEngineConfigurationChange,
|
||||||
|
object: object,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.restartEngine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeConfigObserver() {
|
||||||
|
if let observer = configChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
configChangeObserver = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||||
|
let id = nextScheduledBufferID
|
||||||
|
nextScheduledBufferID &+= 1
|
||||||
|
let completionState = PTTPlaybackCompletionState()
|
||||||
|
scheduledBuffers.append(ScheduledBuffer(
|
||||||
|
id: id,
|
||||||
|
buffer: buffer,
|
||||||
|
completionState: completionState
|
||||||
|
))
|
||||||
|
let generation = engineGeneration
|
||||||
|
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
|
||||||
|
guard event == .dataPlayedBack else { return }
|
||||||
|
// Mark completion before hopping to MainActor. A rebuild can then
|
||||||
|
// distinguish already-completed audio from an unfinished tail
|
||||||
|
// even when this task has not run yet.
|
||||||
|
guard completionState.complete() else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.engineGeneration == generation else { return }
|
||||||
|
self.scheduledBuffers.removeAll { $0.id == id }
|
||||||
|
self.stopIfDrained()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retireScheduledBuffers() {
|
||||||
|
engineGeneration += 1
|
||||||
|
for scheduled in scheduledBuffers {
|
||||||
|
_ = scheduled.completionState.retireIfPending()
|
||||||
|
}
|
||||||
|
scheduledBuffers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopIfDrained() {
|
||||||
|
guard finished, scheduledBuffers.isEmpty else { return }
|
||||||
|
// Started: everything scheduled has played out. Never started with
|
||||||
|
// nothing queued or in flight (e.g. no decodable frames): nothing
|
||||||
|
// will ever play. Otherwise the engine start is still pending (the
|
||||||
|
// async session acquire) and the queued audio must play out first.
|
||||||
|
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseSessionToken() {
|
||||||
|
sessionToken.map(coordinator.release)
|
||||||
|
sessionToken = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PTTBurstPlayer: ExclusivePlayback {
|
||||||
|
/// A live stream can't meaningfully pause; yielding the floor stops it.
|
||||||
|
/// The burst keeps assembling to file, so nothing is lost.
|
||||||
|
nonisolated func pauseForExclusivity() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//
|
||||||
|
// PTTCaptureEngine.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Owns one capture token and returns it even when the capture engine's owner
|
||||||
|
/// disappears without reaching its normal stop/cancel path. The coordinator
|
||||||
|
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
|
||||||
|
/// reclaim an abandoned hold.
|
||||||
|
final class PTTCaptureSessionLease: @unchecked Sendable {
|
||||||
|
private let coordinator: AudioSessionCoordinator
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var token: AudioSessionCoordinator.Token?
|
||||||
|
|
||||||
|
init(coordinator: AudioSessionCoordinator) {
|
||||||
|
self.coordinator = coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(_ token: AudioSessionCoordinator.Token) {
|
||||||
|
let previous = lock.withLock {
|
||||||
|
let previous = self.token
|
||||||
|
self.token = token
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
previous.map(coordinator.release)
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
let token = lock.withLock {
|
||||||
|
let token = self.token
|
||||||
|
self.token = nil
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
token.map(coordinator.release)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monotonic capture identity shared by main-actor lifecycle code and queued
|
||||||
|
/// engine callbacks. Removing a notification observer does not cancel a block
|
||||||
|
/// already enqueued on the main queue, so every callback must also prove it
|
||||||
|
/// still belongs to the current hold before mutating capture state.
|
||||||
|
final class PTTCaptureGeneration: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value: UInt = 0
|
||||||
|
|
||||||
|
func begin() -> UInt {
|
||||||
|
lock.withLock {
|
||||||
|
value &+= 1
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
lock.withLock { value &+= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate(ifCurrent generation: UInt) -> Bool {
|
||||||
|
lock.withLock {
|
||||||
|
guard value == generation else { return false }
|
||||||
|
value &+= 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrent(_ generation: UInt) -> Bool {
|
||||||
|
lock.withLock { value == generation }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
||||||
|
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
||||||
|
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
||||||
|
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
||||||
|
/// handles delivery to receivers that missed the live stream.
|
||||||
|
/// `@unchecked Sendable`: every mutable property is confined to one executor —
|
||||||
|
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
|
||||||
|
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) — so
|
||||||
|
/// weak references may cross the `@Sendable` tap/notification closures, which
|
||||||
|
/// immediately hop back to the owning executor.
|
||||||
|
final class PTTCaptureEngine: @unchecked Sendable {
|
||||||
|
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
||||||
|
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
||||||
|
private static let maxCaptureDuration: TimeInterval = 120
|
||||||
|
|
||||||
|
/// Recreated on every `start()`: an engine whose input unit was
|
||||||
|
/// instantiated against an earlier (playback-only or inactive) audio
|
||||||
|
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
|
||||||
|
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
||||||
|
private var engine = AVAudioEngine()
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
||||||
|
private let coordinator: AudioSessionCoordinator
|
||||||
|
private let sessionLease: PTTCaptureSessionLease
|
||||||
|
private let captureGeneration = PTTCaptureGeneration()
|
||||||
|
|
||||||
|
// Capture-queue-confined state.
|
||||||
|
private var resampler: PTTInputResampler?
|
||||||
|
private var encoder: PTTFrameEncoder?
|
||||||
|
private var file: AVAudioFile?
|
||||||
|
private var fileURL: URL?
|
||||||
|
private var encodedFrameCount = 0
|
||||||
|
private var running = false
|
||||||
|
private var captureStart = Date()
|
||||||
|
/// Whether `engine.start()` succeeded for the current capture
|
||||||
|
/// (see `stopEngineIfStarted`).
|
||||||
|
@MainActor private var engineStarted = false
|
||||||
|
@MainActor private var configChangeObserver: NSObjectProtocol?
|
||||||
|
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||||
|
var onFrames: (([Data]) -> Void)?
|
||||||
|
|
||||||
|
enum CaptureError: Error {
|
||||||
|
case inputUnavailable
|
||||||
|
case audioSetupFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
init(coordinator: AudioSessionCoordinator = .shared) {
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
sessionLease.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async because acquiring the session hops its blocking IPC off the main
|
||||||
|
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
|
||||||
|
/// itself still starts back on main once the session is configured.
|
||||||
|
@MainActor
|
||||||
|
func start(outputURL: URL) async throws {
|
||||||
|
let generation = captureGeneration.begin()
|
||||||
|
let token = try await coordinator.acquire(.capture) { [weak self] in
|
||||||
|
self?.handleInterruption(for: generation)
|
||||||
|
}
|
||||||
|
// The hold ended (stop/cancel) while the session was activating:
|
||||||
|
// starting the engine now would leave a hot mic after release.
|
||||||
|
guard captureGeneration.isCurrent(generation) else {
|
||||||
|
coordinator.release(token)
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
sessionLease.install(token)
|
||||||
|
do {
|
||||||
|
try beginCapture(outputURL: outputURL, generation: generation)
|
||||||
|
} catch {
|
||||||
|
releaseSessionToken()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func beginCapture(outputURL: URL, generation: UInt) throws {
|
||||||
|
// Fresh engine per capture so its input unit binds to the session
|
||||||
|
// that is active *now* (see `engine` doc comment).
|
||||||
|
engine = AVAudioEngine()
|
||||||
|
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||||
|
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
||||||
|
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||||
|
throw CaptureError.inputUnavailable
|
||||||
|
}
|
||||||
|
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
|
||||||
|
let encoder = PTTFrameEncoder(),
|
||||||
|
let pcmFormat = PTTAudioFormat.pcmFormat
|
||||||
|
else { throw CaptureError.audioSetupFailed }
|
||||||
|
|
||||||
|
let file = try AVAudioFile(
|
||||||
|
forWriting: outputURL,
|
||||||
|
settings: PTTAudioFormat.voiceNoteFileSettings,
|
||||||
|
commonFormat: pcmFormat.commonFormat,
|
||||||
|
interleaved: pcmFormat.isInterleaved
|
||||||
|
)
|
||||||
|
|
||||||
|
queue.sync {
|
||||||
|
self.resampler = resampler
|
||||||
|
self.encoder = encoder
|
||||||
|
self.file = file
|
||||||
|
self.fileURL = outputURL
|
||||||
|
self.encodedFrameCount = 0
|
||||||
|
self.captureStart = Date()
|
||||||
|
self.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||||
|
self?.queue.async { self?.process(buffer, generation: generation) }
|
||||||
|
}
|
||||||
|
// Route/category changes reconfigure the engine underneath the tap;
|
||||||
|
// stop and finalize cleanly — the .m4a captured so far still sends.
|
||||||
|
// Registered before start() so no reconfigure lands unobserved
|
||||||
|
// (handleInterruption also validates this capture generation).
|
||||||
|
configChangeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVAudioEngineConfigurationChange,
|
||||||
|
object: engine,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleInterruption(for: generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine.prepare()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
||||||
|
if let observer = configChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
configChangeObserver = nil
|
||||||
|
}
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
queue.sync { self.teardown(deleteFile: true) }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
engineStarted = true
|
||||||
|
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
||||||
|
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
||||||
|
@MainActor
|
||||||
|
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||||
|
captureGeneration.invalidate()
|
||||||
|
stopEngineIfStarted()
|
||||||
|
let result: (URL?, Int) = queue.sync {
|
||||||
|
let url = fileURL
|
||||||
|
let frames = encodedFrameCount
|
||||||
|
teardown(deleteFile: false)
|
||||||
|
return (url, frames)
|
||||||
|
}
|
||||||
|
releaseSessionToken()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func cancel() {
|
||||||
|
captureGeneration.invalidate()
|
||||||
|
stopEngineIfStarted()
|
||||||
|
queue.sync { teardown(deleteFile: true) }
|
||||||
|
releaseSessionToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio session interrupted (call, Siri) or the engine was reconfigured
|
||||||
|
/// mid-capture: behave like `stop()` — finalize the `.m4a` container but
|
||||||
|
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
|
||||||
|
/// still returns the note for delivery.
|
||||||
|
@MainActor
|
||||||
|
private func handleInterruption(for generation: UInt) {
|
||||||
|
// Also invalidate a start whose acquire has registered its token but
|
||||||
|
// has not returned to this actor yet. Without this bump the callback
|
||||||
|
// is lost while `engineStarted == false`, and the resumed start can
|
||||||
|
// open the mic after the stop signal.
|
||||||
|
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
|
||||||
|
guard engineStarted else {
|
||||||
|
releaseSessionToken()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopEngineIfStarted()
|
||||||
|
queue.sync {
|
||||||
|
running = false
|
||||||
|
// Releasing the AVAudioFile finalizes the .m4a container.
|
||||||
|
file = nil
|
||||||
|
encoder = nil
|
||||||
|
resampler = nil
|
||||||
|
}
|
||||||
|
releaseSessionToken()
|
||||||
|
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touching `inputNode` on an engine that never started instantiates its
|
||||||
|
/// input unit against whatever session is active and spams AURemoteIO
|
||||||
|
/// errors — a canceled-before-start hold must not touch the engine.
|
||||||
|
@MainActor
|
||||||
|
private func stopEngineIfStarted() {
|
||||||
|
if let observer = configChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
configChangeObserver = nil
|
||||||
|
}
|
||||||
|
guard engineStarted else { return }
|
||||||
|
engineStarted = false
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func releaseSessionToken() {
|
||||||
|
sessionLease.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture queue
|
||||||
|
|
||||||
|
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
|
||||||
|
guard captureGeneration.isCurrent(generation),
|
||||||
|
running,
|
||||||
|
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||||
|
let resampled = resampler?.resample(buffer)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try file?.write(from: resampled)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
|
||||||
|
encodedFrameCount += frames.count
|
||||||
|
onFrames?(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardown(deleteFile: Bool) {
|
||||||
|
running = false
|
||||||
|
// Releasing the AVAudioFile finalizes the .m4a container.
|
||||||
|
file = nil
|
||||||
|
encoder = nil
|
||||||
|
resampler = nil
|
||||||
|
if deleteFile, let url = fileURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
fileURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// PTTSettings.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// User preference for live push-to-talk voice. One switch controls both
|
||||||
|
/// directions: streaming your holds live, and auto-playing inbound bursts.
|
||||||
|
/// Off means voice messages behave exactly like classic voice notes.
|
||||||
|
enum PTTSettings {
|
||||||
|
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
|
||||||
|
|
||||||
|
static var liveVoiceEnabled: Bool {
|
||||||
|
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autoplay is foreground-only: audio must never start from the
|
||||||
|
/// background.
|
||||||
|
@MainActor
|
||||||
|
static var isAppActive: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return UIApplication.shared.applicationState == .active
|
||||||
|
#elseif os(macOS)
|
||||||
|
return NSApplication.shared.isActive
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//
|
||||||
|
// VoiceCaptureSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Capture backend behind the composer's hold-to-record gesture.
|
||||||
|
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
|
||||||
|
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
|
||||||
|
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
|
||||||
|
/// additionally streams frames live while the button is held.
|
||||||
|
@MainActor
|
||||||
|
protocol VoiceCaptureSession: AnyObject {
|
||||||
|
/// Whether audio is leaving the device in real time while recording —
|
||||||
|
/// drives the composer's LIVE treatment.
|
||||||
|
var isLive: Bool { get }
|
||||||
|
func requestPermission() async -> Bool
|
||||||
|
func start() async throws
|
||||||
|
/// Stops capture and returns the finalized voice-note file, or nil when
|
||||||
|
/// nothing valid was captured.
|
||||||
|
func finish() async -> URL?
|
||||||
|
func cancel() async
|
||||||
|
/// Stops capture and suppresses every later send before returning.
|
||||||
|
func panicCancelSynchronously()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
|
@MainActor
|
||||||
|
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||||
|
private let recorder: VoiceRecorder
|
||||||
|
private let owner = VoiceRecorder.RecordingOwner()
|
||||||
|
|
||||||
|
var isLive: Bool { false }
|
||||||
|
|
||||||
|
init(recorder: VoiceRecorder = .shared) {
|
||||||
|
self.recorder = recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
await recorder.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
try await recorder.startRecording(owner: owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async -> URL? {
|
||||||
|
await recorder.stopRecording(owner: owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
await recorder.cancelRecording(owner: owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
recorder.panicCancelSynchronously(owner: owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Testable surface of the live capture engine. Production uses
|
||||||
|
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
|
||||||
|
/// real audio hardware.
|
||||||
|
@MainActor
|
||||||
|
protocol PTTCapturing: AnyObject {
|
||||||
|
var onFrames: (([Data]) -> Void)? { get set }
|
||||||
|
func start(outputURL: URL) async throws
|
||||||
|
func stop() -> (url: URL?, encodedFrames: Int)
|
||||||
|
func cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PTTCaptureEngine: PTTCapturing {}
|
||||||
|
|
||||||
|
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
||||||
|
/// recording, then finalizes the same audio as a standard voice note whose
|
||||||
|
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
||||||
|
/// heard the live stream absorb the note silently instead of seeing a
|
||||||
|
/// duplicate.
|
||||||
|
@MainActor
|
||||||
|
final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||||
|
let burstID: Data
|
||||||
|
|
||||||
|
private let sendPacket: (Data) -> Void
|
||||||
|
private let capture: any PTTCapturing
|
||||||
|
private let now: () -> Date
|
||||||
|
/// Capture-queue-confined stream state: packetizes frames and lazily
|
||||||
|
/// emits START so packet order is guaranteed by queue serialization.
|
||||||
|
private final class StreamState {
|
||||||
|
var packetizer: VoiceBurstPacketizer
|
||||||
|
var sentStart = false
|
||||||
|
init(burstID: Data) {
|
||||||
|
packetizer = VoiceBurstPacketizer(burstID: burstID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private let stream: StreamState
|
||||||
|
private var startDate: Date?
|
||||||
|
private var completed = false
|
||||||
|
|
||||||
|
var isLive: Bool { true }
|
||||||
|
|
||||||
|
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
||||||
|
/// target peer; must be safe to call from any queue (BLEService hops to
|
||||||
|
/// its own message queue internally).
|
||||||
|
init(
|
||||||
|
sendPacket: @escaping (Data) -> Void,
|
||||||
|
capture: (any PTTCapturing)? = nil,
|
||||||
|
now: @escaping () -> Date = Date.init,
|
||||||
|
burstID: Data? = nil
|
||||||
|
) {
|
||||||
|
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
|
||||||
|
self.sendPacket = sendPacket
|
||||||
|
self.capture = capture ?? PTTCaptureEngine()
|
||||||
|
self.now = now
|
||||||
|
self.stream = StreamState(burstID: self.burstID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
await VoiceRecorder.shared.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
let outputURL = try Self.makeOutputURL(burstID: burstID)
|
||||||
|
let sendPacket = sendPacket
|
||||||
|
let stream = stream
|
||||||
|
capture.onFrames = { frames in
|
||||||
|
if !stream.sentStart {
|
||||||
|
stream.sentStart = true
|
||||||
|
if let start = VoiceBurstPacket(
|
||||||
|
burstID: stream.packetizer.burstID,
|
||||||
|
seq: 0,
|
||||||
|
kind: .start(codec: .aacLC16kMono)
|
||||||
|
) {
|
||||||
|
sendPacket(start.encode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for frame in frames {
|
||||||
|
for packet in stream.packetizer.add(frame) {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush per callback batch: at ~130-byte frames the budget fits
|
||||||
|
// one frame per packet anyway, and holding residue would add
|
||||||
|
// ~100 ms of avoidable latency.
|
||||||
|
for packet in stream.packetizer.flush() {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await capture.start(outputURL: outputURL)
|
||||||
|
} catch is CancellationError {
|
||||||
|
// The hold was released/canceled while the session acquire was
|
||||||
|
// in flight: the engine never started and the capture already
|
||||||
|
// handed its token back — nothing to retry. A coordinator-side
|
||||||
|
// interruption during handoff also cancels acquire, but that is
|
||||||
|
// not a successful start and must propagate to the view model.
|
||||||
|
guard completed else { throw CancellationError() }
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// The HAL can briefly report a dead input right after the audio
|
||||||
|
// session (re)activates while the route settles; one retry after
|
||||||
|
// a short pause covers it (observed on iPhone field tests).
|
||||||
|
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
|
||||||
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
// The hold may have been released/canceled during the retry pause.
|
||||||
|
// Starting the mic now would leave it live and streaming after the
|
||||||
|
// user let go, so bail instead of opening a hot mic.
|
||||||
|
guard !completed else {
|
||||||
|
capture.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await capture.start(outputURL: outputURL)
|
||||||
|
}
|
||||||
|
startDate = now()
|
||||||
|
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async -> URL? {
|
||||||
|
guard !completed else { return nil }
|
||||||
|
completed = true
|
||||||
|
|
||||||
|
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
|
||||||
|
let (url, encodedFrames) = capture.stop()
|
||||||
|
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||||
|
|
||||||
|
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
|
||||||
|
|
||||||
|
guard elapsed >= VoiceRecorder.minRecordingDuration,
|
||||||
|
capturedDuration >= VoiceRecorder.minRecordingDuration,
|
||||||
|
let url
|
||||||
|
else {
|
||||||
|
sendControlPacket(.canceled)
|
||||||
|
if let url {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for packet in stream.packetizer.flush() {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
let durationMs = UInt32((capturedDuration * 1000).rounded())
|
||||||
|
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
|
||||||
|
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
let alreadyCompleted = completed
|
||||||
|
completed = true
|
||||||
|
// Always tear down the capture, even if a quick-release already marked
|
||||||
|
// us completed: the engine can start late (during start()'s retry
|
||||||
|
// pause), and only capture.cancel() stops the mic and deactivates the
|
||||||
|
// session. It is idempotent, so a redundant call is harmless.
|
||||||
|
capture.cancel()
|
||||||
|
if !alreadyCompleted {
|
||||||
|
sendControlPacket(.canceled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
// Do not emit a canceled packet: it would itself be pre-panic
|
||||||
|
// conversation data racing the emergency transport reset.
|
||||||
|
completed = true
|
||||||
|
capture.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
|
sendPacket(packet.encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeOutputURL(burstID: Data) throws -> URL {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
@Published private(set) var duration: TimeInterval = 0
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
@Published private(set) var progress: Double = 0
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
|
/// Internal lifecycle visibility for deterministic acquisition tests.
|
||||||
|
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
|
||||||
|
|
||||||
/// rounded so 4.9s shows "00:05"
|
/// rounded so 4.9s shows "00:05"
|
||||||
var roundedDuration: Int {
|
var roundedDuration: Int {
|
||||||
guard duration.isFinite else { return 0 }
|
guard duration.isFinite else { return 0 }
|
||||||
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
private var player: AVAudioPlayer?
|
private var player: AVAudioPlayer?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var url: URL
|
private var url: URL
|
||||||
|
/// Test seam; `AudioSessionCoordinator.shared` when nil.
|
||||||
|
private let sessionCoordinatorOverride: AudioSessionCoordinator?
|
||||||
|
/// Injectable so tests don't fight over the app-wide exclusive-playback
|
||||||
|
/// slot (a parallel test's `play()` would pause this controller mid-test).
|
||||||
|
private let exclusivity: VoiceNotePlaybackCoordinator
|
||||||
|
private var sessionToken: AudioSessionCoordinator.Token?
|
||||||
|
/// A session acquire is in flight (it suspends off-main for the blocking
|
||||||
|
/// session IPC); gates against double acquisition on rapid play taps.
|
||||||
|
private var sessionAcquireInFlight = false
|
||||||
|
|
||||||
init(url: URL) {
|
init(
|
||||||
|
url: URL,
|
||||||
|
sessionCoordinator: AudioSessionCoordinator? = nil,
|
||||||
|
exclusivity: VoiceNotePlaybackCoordinator? = nil
|
||||||
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.sessionCoordinatorOverride = sessionCoordinator
|
||||||
|
self.exclusivity = exclusivity ?? .shared
|
||||||
super.init()
|
super.init()
|
||||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||||
}
|
}
|
||||||
@@ -51,6 +69,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
|
player?.stop()
|
||||||
|
// A per-row @StateObject can be discarded mid-playback (navigating
|
||||||
|
// away). Leaking the token here would hold the session forever —
|
||||||
|
// never deactivating it, and pinning any escalated category for the
|
||||||
|
// app's lifetime. `release` is fire-and-forget onto the coordinator's
|
||||||
|
// queue, so it is deinit-safe: only the Sendable token crosses.
|
||||||
|
if let token = sessionToken {
|
||||||
|
sessionToken = nil
|
||||||
|
(sessionCoordinatorOverride ?? .shared).release(token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceURL(_ url: URL) {
|
func replaceURL(_ url: URL) {
|
||||||
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
guard ensurePlayerReady() else { return }
|
guard ensurePlayerReady() else { return }
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
exclusivity.activate(self)
|
||||||
player?.play()
|
isPlaying = true
|
||||||
startTimer()
|
startTimer()
|
||||||
updateProgress()
|
updateProgress()
|
||||||
isPlaying = true
|
// Acquired here (not in ensurePlayerReady): scrubbing a paused note
|
||||||
|
// must not hold the session while nothing is audible. The session
|
||||||
|
// calls block on audio-server IPC, so they run off the main thread;
|
||||||
|
// the player starts once the session is configured.
|
||||||
|
startPlayerAfterAcquiringSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
updateProgress()
|
updateProgress()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
releaseSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
updateProgress()
|
updateProgress()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
releaseSession()
|
||||||
|
exclusivity.deactivate(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to fraction: Double) {
|
func seek(to fraction: Double) {
|
||||||
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
let clamped = max(0, min(1, fraction))
|
let clamped = max(0, min(1, fraction))
|
||||||
if let player = player {
|
if let player = player {
|
||||||
player.currentTime = clamped * player.duration
|
player.currentTime = clamped * player.duration
|
||||||
if isPlaying {
|
// While the session acquire is still in flight, don't start
|
||||||
player.play()
|
// audio pre-activation — the pending acquire's completion starts
|
||||||
|
// playback (from the new position) once the session resolves.
|
||||||
|
if isPlaying, !sessionAcquireInFlight {
|
||||||
|
startPreparedPlayer()
|
||||||
}
|
}
|
||||||
updateProgress()
|
updateProgress()
|
||||||
}
|
}
|
||||||
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
self.stopTimer()
|
self.stopTimer()
|
||||||
self.updateProgress()
|
self.updateProgress()
|
||||||
self.isPlaying = false
|
self.isPlaying = false
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
self.releaseSession()
|
||||||
|
self.exclusivity.deactivate(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func preparePlayer(for url: URL) {
|
private func preparePlayer(for url: URL) {
|
||||||
// Prepare player synchronously (only called when playback is requested)
|
// Load metadata synchronously, but do not call prepareToPlay here:
|
||||||
|
// paused scrubbing reaches this path and must not acquire playback
|
||||||
|
// hardware outside the AudioSessionCoordinator token lifetime.
|
||||||
do {
|
do {
|
||||||
let player = try AVAudioPlayer(contentsOf: url)
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
player.delegate = self
|
player.delegate = self
|
||||||
player.prepareToPlay()
|
|
||||||
self.player = player
|
self.player = player
|
||||||
duration = player.duration
|
duration = player.duration
|
||||||
currentTime = player.currentTime
|
currentTime = player.currentTime
|
||||||
@@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
if player == nil {
|
if player == nil {
|
||||||
preparePlayer(for: url)
|
preparePlayer(for: url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
do {
|
|
||||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
|
||||||
try session.setActive(true, options: [])
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return player != nil
|
return player != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
|
||||||
|
/// delegate's main-queue hop) run on the main thread; the acquire itself
|
||||||
|
/// suspends while the blocking session IPC runs on the coordinator's
|
||||||
|
/// queue, and the player starts when it resolves. An acquire failure
|
||||||
|
/// leaves playback stopped: starting without a registered token would
|
||||||
|
/// bypass interruption fan-out and the coordinator's refcount. A
|
||||||
|
/// pause/stop landing mid-acquire hands the token straight back.
|
||||||
|
private func startPlayerAfterAcquiringSession() {
|
||||||
|
if sessionToken != nil {
|
||||||
|
startPreparedPlayer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !sessionAcquireInFlight else { return }
|
||||||
|
sessionAcquireInFlight = true
|
||||||
|
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
var token: AudioSessionCoordinator.Token?
|
||||||
|
do {
|
||||||
|
token = try await coordinator.acquire(.playback) { [weak self] in
|
||||||
|
self?.pause()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
guard let self else {
|
||||||
|
// The row was discarded while acquiring; deinit had no token
|
||||||
|
// to release yet.
|
||||||
|
token.map(coordinator.release)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sessionAcquireInFlight = false
|
||||||
|
guard self.isPlaying else {
|
||||||
|
// Paused/stopped while the session was activating.
|
||||||
|
token.map(coordinator.release)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let token else {
|
||||||
|
self.failPlaybackStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sessionToken = token
|
||||||
|
self.startPreparedPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func startPreparedPlayer() -> Bool {
|
||||||
|
guard let player,
|
||||||
|
player.prepareToPlay(),
|
||||||
|
player.play()
|
||||||
|
else {
|
||||||
|
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
|
||||||
|
failPlaybackStart()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failPlaybackStart() {
|
||||||
|
player?.pause()
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
releaseSession()
|
||||||
|
exclusivity.deactivate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseSession() {
|
||||||
|
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
|
||||||
|
sessionToken = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func startTimer() {
|
private func startTimer() {
|
||||||
if timer != nil { return }
|
if timer != nil { return }
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
@@ -181,25 +283,75 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures only one voice note plays at a time.
|
/// Something that can hold the app's single audio-playback slot and yield it
|
||||||
|
/// when another playback starts (voice notes pause; live bursts stop).
|
||||||
|
protocol ExclusivePlayback: AnyObject {
|
||||||
|
func pauseForExclusivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||||
|
func pauseForExclusivity() {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures only one voice playback (note or live burst) runs at a time.
|
||||||
final class VoiceNotePlaybackCoordinator {
|
final class VoiceNotePlaybackCoordinator {
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
private weak var activeController: VoiceNotePlaybackController?
|
struct Reservation: Equatable {
|
||||||
|
fileprivate let generation: UInt64
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func activate(_ controller: VoiceNotePlaybackController) {
|
|
||||||
if activeController === controller {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeController?.pause()
|
|
||||||
activeController = controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
private weak var activeController: (any ExclusivePlayback)?
|
||||||
|
private weak var latestReservedController: (any ExclusivePlayback)?
|
||||||
|
private var latestReservation = Reservation(generation: 0)
|
||||||
|
|
||||||
|
/// Internal so tests can isolate their own exclusivity slot; the app
|
||||||
|
/// uses `shared`.
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
/// Records playback intent without interrupting audio that is already
|
||||||
|
/// audible. Async starters reserve before suspension, then activate only
|
||||||
|
/// after their audio resource is ready.
|
||||||
|
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
|
||||||
|
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
|
||||||
|
latestReservedController = controller
|
||||||
|
return latestReservation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immediate activation for synchronous/user-initiated playback.
|
||||||
|
@discardableResult
|
||||||
|
func activate(_ controller: any ExclusivePlayback) -> Reservation {
|
||||||
|
let reservation = reserve(controller)
|
||||||
|
_ = activate(controller, reservation: reservation)
|
||||||
|
return reservation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits an earlier reservation only when it is still the newest
|
||||||
|
/// playback request. This prevents an older async acquire from stealing
|
||||||
|
/// the floor after a newer play gesture.
|
||||||
|
@discardableResult
|
||||||
|
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
|
||||||
|
guard isCurrent(reservation, for: controller) else { return false }
|
||||||
|
if activeController === controller {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
activeController?.pauseForExclusivity()
|
||||||
|
activeController = controller
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
|
||||||
|
latestReservation == reservation && latestReservedController === controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
activeController = nil
|
activeController = nil
|
||||||
}
|
}
|
||||||
|
if latestReservedController === controller {
|
||||||
|
latestReservedController = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,95 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
|
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
|
||||||
|
/// it behind a protocol lets lifecycle races be tested without opening the
|
||||||
|
/// microphone on the test host.
|
||||||
|
protocol VoiceAudioRecording: AnyObject {
|
||||||
|
var isRecording: Bool { get }
|
||||||
|
var isMeteringEnabled: Bool { get set }
|
||||||
|
func prepareToRecord() -> Bool
|
||||||
|
func record(forDuration duration: TimeInterval) -> Bool
|
||||||
|
func stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AVAudioRecorder: VoiceAudioRecording {}
|
||||||
|
|
||||||
|
protocol VoiceAudioRecorderCreating {
|
||||||
|
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
||||||
|
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 16_000
|
||||||
|
]
|
||||||
|
return try AVAudioRecorder(url: url, settings: settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
actor VoiceRecorder {
|
actor VoiceRecorder {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error, Equatable {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
|
||||||
case recordingInProgress
|
case recordingInProgress
|
||||||
|
case failedToStartRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
static let shared = VoiceRecorder()
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
|
||||||
static let minRecordingDuration: TimeInterval = 1
|
static let minRecordingDuration: TimeInterval = 1
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
/// Identity of one press/hold. Every lifecycle mutation must present the
|
||||||
|
/// same owner that started the recorder, so a stale finish or cancel from
|
||||||
|
/// another hold cannot stop or delete the current recording.
|
||||||
|
final class RecordingOwner: @unchecked Sendable {}
|
||||||
|
|
||||||
|
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
|
||||||
|
/// on wall-clock sleeps. Production uses the real padding delay.
|
||||||
|
struct TestingHooks: Sendable {
|
||||||
|
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
|
||||||
|
|
||||||
|
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
|
||||||
|
self.waitForStopPadding = waitForStopPadding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let sessionCoordinator: AudioSessionCoordinator
|
||||||
|
private let recorderFactory: any VoiceAudioRecorderCreating
|
||||||
|
private let permissionGranted: () -> Bool
|
||||||
|
private let paddingInterval: TimeInterval
|
||||||
|
private let maxRecordingDuration: TimeInterval
|
||||||
|
private let outputDirectory: URL?
|
||||||
|
private let testingHooks: TestingHooks
|
||||||
|
|
||||||
|
private var recorder: (any VoiceAudioRecording)?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
|
private var sessionToken: AudioSessionCoordinator.Token?
|
||||||
|
private var activeOwner: RecordingOwner?
|
||||||
|
/// True only while `startRecording()` is suspended in session acquire.
|
||||||
|
/// A second start is rejected instead of superseding the first one.
|
||||||
|
private var startInFlight = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
sessionCoordinator: AudioSessionCoordinator = .shared,
|
||||||
|
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
|
||||||
|
permissionGranted: (() -> Bool)? = nil,
|
||||||
|
paddingInterval: TimeInterval = 0.5,
|
||||||
|
maxRecordingDuration: TimeInterval = 120,
|
||||||
|
outputDirectory: URL? = nil,
|
||||||
|
testingHooks: TestingHooks = TestingHooks()
|
||||||
|
) {
|
||||||
|
self.sessionCoordinator = sessionCoordinator
|
||||||
|
self.recorderFactory = recorderFactory
|
||||||
|
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
|
||||||
|
self.paddingInterval = paddingInterval
|
||||||
|
self.maxRecordingDuration = maxRecordingDuration
|
||||||
|
self.outputDirectory = outputDirectory
|
||||||
|
self.testingHooks = testingHooks
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
@@ -42,82 +115,130 @@ actor VoiceRecorder {
|
|||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func startRecording() throws -> URL {
|
func startRecording(owner: RecordingOwner) async throws -> URL {
|
||||||
if recorder?.isRecording == true {
|
if activeOwner != nil {
|
||||||
throw RecorderError.recordingInProgress
|
throw RecorderError.recordingInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
guard permissionGranted() else {
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
guard session.recordPermission == .granted else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
throw RecorderError.microphoneAccessDenied
|
||||||
}
|
}
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
activeOwner = owner
|
||||||
try session.setCategory(
|
startInFlight = true
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
// The acquire suspends while the blocking session IPC runs on the
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
// coordinator's queue (never this actor's thread or main).
|
||||||
)
|
let token: AudioSessionCoordinator.Token
|
||||||
#else
|
do {
|
||||||
try session.setCategory(
|
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
|
||||||
.playAndRecord,
|
Task { await self?.handleSessionInterruption(for: owner) }
|
||||||
mode: .default,
|
}
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
} catch {
|
||||||
)
|
guard activeOwner === owner else {
|
||||||
#endif
|
throw CancellationError()
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
}
|
||||||
#endif
|
startInFlight = false
|
||||||
#if os(macOS)
|
activeOwner = nil
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
throw error
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
// Actor reentrancy: release/cancel may have ended this hold while the
|
||||||
let settings: [String: Any] = [
|
// blocking session activation was still in progress.
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
guard activeOwner === owner, startInFlight else {
|
||||||
AVSampleRateKey: 16_000,
|
sessionCoordinator.release(token)
|
||||||
AVNumberOfChannelsKey: 1,
|
throw CancellationError()
|
||||||
AVEncoderBitRateKey: 16_000
|
}
|
||||||
]
|
startInFlight = false
|
||||||
|
sessionToken = token
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
var outputURL: URL?
|
||||||
audioRecorder.isMeteringEnabled = true
|
do {
|
||||||
audioRecorder.prepareToRecord()
|
let newURL = try makeOutputURL()
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
outputURL = newURL
|
||||||
|
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
|
||||||
|
audioRecorder.isMeteringEnabled = true
|
||||||
|
guard audioRecorder.prepareToRecord() else {
|
||||||
|
throw RecorderError.failedToStartRecording
|
||||||
|
}
|
||||||
|
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
|
||||||
|
throw RecorderError.failedToStartRecording
|
||||||
|
}
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = newURL
|
||||||
return outputURL
|
return newURL
|
||||||
|
} catch {
|
||||||
|
releaseSessionToken()
|
||||||
|
recorder = nil
|
||||||
|
currentURL = nil
|
||||||
|
activeOwner = nil
|
||||||
|
if let outputURL {
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() async -> URL? {
|
func stopRecording(owner: RecordingOwner) async -> URL? {
|
||||||
guard let recorder, recorder.isRecording else {
|
guard activeOwner === owner else { return nil }
|
||||||
return currentURL
|
|
||||||
|
// `finish()` can race a still-suspended start on a direct caller even
|
||||||
|
// though the UI normally routes quick releases through cancel().
|
||||||
|
if startInFlight {
|
||||||
|
activeOwner = nil
|
||||||
|
startInFlight = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeRecorder = recorder else {
|
||||||
|
let sessionURL = currentURL
|
||||||
|
releaseSessionToken()
|
||||||
|
currentURL = nil
|
||||||
|
activeOwner = nil
|
||||||
|
return sessionURL
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessionURL = currentURL
|
let sessionURL = currentURL
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
if activeRecorder.isRecording, paddingInterval > 0 {
|
||||||
|
if let waitForStopPadding = testingHooks.waitForStopPadding {
|
||||||
recorder.stop()
|
await waitForStopPadding(paddingInterval)
|
||||||
|
} else {
|
||||||
// A new session may have started during the sleep — don't touch its state
|
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||||
if self.recorder === recorder {
|
}
|
||||||
cleanupSession()
|
|
||||||
self.recorder = nil
|
|
||||||
currentURL = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancellation or interruption may have run during the padding sleep.
|
||||||
|
// Only the recorder whose stop began here may be finalized by it.
|
||||||
|
guard activeOwner === owner,
|
||||||
|
let recorder = self.recorder,
|
||||||
|
recorder === activeRecorder
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if activeRecorder.isRecording {
|
||||||
|
activeRecorder.stop()
|
||||||
|
}
|
||||||
|
releaseSessionToken()
|
||||||
|
self.recorder = nil
|
||||||
|
currentURL = nil
|
||||||
|
activeOwner = nil
|
||||||
|
|
||||||
return sessionURL
|
return sessionURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRecording() {
|
func cancelRecording(owner: RecordingOwner) async {
|
||||||
|
guard activeOwner === owner else { return }
|
||||||
|
|
||||||
|
// Invalidate ownership before cleanup. An actor-reentrant start whose
|
||||||
|
// session acquire resumes later will observe the mismatch and release
|
||||||
|
// its token without opening the microphone.
|
||||||
|
activeOwner = nil
|
||||||
|
startInFlight = false
|
||||||
if let recorder, recorder.isRecording {
|
if let recorder, recorder.isRecording {
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
}
|
}
|
||||||
cleanupSession()
|
releaseSessionToken()
|
||||||
if let currentURL {
|
if let currentURL {
|
||||||
try? FileManager.default.removeItem(at: currentURL)
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
}
|
}
|
||||||
@@ -125,14 +246,60 @@ 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 recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||||
|
/// `stopRecording()` still returns the partial note.
|
||||||
|
private func handleSessionInterruption(for owner: RecordingOwner) async {
|
||||||
|
// A callback captured for a released token must never stop a newer
|
||||||
|
// recording. Conversely, an interruption delivered while acquire is
|
||||||
|
// still suspended invalidates that acquire before it can open the mic.
|
||||||
|
guard activeOwner === owner else { return }
|
||||||
|
if startInFlight {
|
||||||
|
activeOwner = nil
|
||||||
|
startInFlight = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startInFlight = false
|
||||||
|
if let recorder, recorder.isRecording {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
releaseSessionToken()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static func hasSystemPermission() -> Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
AVAudioSession.sharedInstance().recordPermission == .granted
|
||||||
|
#elseif os(macOS)
|
||||||
|
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
#else
|
||||||
|
true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func makeOutputURL() throws -> URL {
|
private func makeOutputURL() throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
|
||||||
|
|
||||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
let baseDirectory = try outputDirectory
|
||||||
|
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return baseDirectory.appendingPathComponent(fileName)
|
return baseDirectory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
@@ -147,9 +314,11 @@ actor VoiceRecorder {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupSession() {
|
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
|
||||||
#if os(iOS)
|
/// onto its own queue.
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
private func releaseSessionToken() {
|
||||||
#endif
|
guard let token = sessionToken else { return }
|
||||||
|
sessionToken = nil
|
||||||
|
sessionCoordinator.release(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ final class WaveformCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func purgeAll() {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
guard bins > 0 else { return nil }
|
guard bins > 0 else { return nil }
|
||||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ import BitFoundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: PeerID // 8 random bytes
|
|
||||||
let sessionStart: Date
|
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +96,6 @@ enum HandshakeState {
|
|||||||
case initiated
|
case initiated
|
||||||
case inProgress
|
case inProgress
|
||||||
case completed(fingerprint: String)
|
case completed(fingerprint: String)
|
||||||
case failed(reason: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||||
@@ -110,7 +107,6 @@ struct CryptographicIdentity: Codable {
|
|||||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||||
var signingPublicKey: Data? = nil
|
var signingPublicKey: Data? = nil
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
let lastHandshake: Date?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||||
@@ -193,9 +189,6 @@ struct IdentityCache: Codable {
|
|||||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||||
// entries verified before this field exists sort as oldest)
|
// entries verified before this field exists sort as oldest)
|
||||||
var verifiedAt: [String: Date]? = nil
|
var verifiedAt: [String: Date]? = nil
|
||||||
|
|
||||||
// Schema version for future migrations
|
|
||||||
var version: Int = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -108,8 +108,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
func getFavorites() -> Set<String>
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
// MARK: Blocked Users Management
|
// MARK: Blocked Users Management
|
||||||
@@ -123,7 +121,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
@@ -139,7 +136,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||||
func isVouched(fingerprint: String) -> Bool
|
func isVouched(fingerprint: String) -> Bool
|
||||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
|
|
||||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
@@ -322,21 +318,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen
|
||||||
lastHandshake: now
|
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
} else {
|
} else {
|
||||||
// Update signing key and lastHandshake
|
// Update signing key
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
let updated = CryptographicIdentity(
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
fingerprint: existing.fingerprint,
|
|
||||||
publicKey: existing.publicKey,
|
|
||||||
signingPublicKey: existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = updated
|
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -345,8 +333,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
firstSeen: now,
|
firstSeen: now
|
||||||
lastHandshake: now
|
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
@@ -511,11 +498,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||||
peerID: peerID,
|
|
||||||
sessionStart: Date(),
|
|
||||||
handshakeState: handshakeState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,24 +31,20 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.social-networking</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSBonjourServices</key>
|
|
||||||
<array>
|
|
||||||
<string>_bitchat-bulk._tcp</string>
|
|
||||||
</array>
|
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ extension BitchatMessage {
|
|||||||
enum Media {
|
enum Media {
|
||||||
case voice(URL)
|
case voice(URL)
|
||||||
case image(URL)
|
case image(URL)
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
switch self {
|
|
||||||
case .voice(let url), .image(let url):
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ struct BitchatPeer: Equatable {
|
|||||||
let peerID: PeerID // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let isReachable: Bool
|
let isReachable: Bool
|
||||||
|
|
||||||
@@ -77,14 +76,13 @@ struct BitchatPeer: Equatable {
|
|||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen _: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.peerID = peerID
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isReachable = isReachable
|
self.isReachable = isReachable
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case unfavorite = "unfav"
|
case unfavorite = "unfav"
|
||||||
case ping
|
case ping
|
||||||
case trace
|
case trace
|
||||||
|
case drop
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable {
|
|||||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||||
case .pay:
|
case .pay:
|
||||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||||
|
case .drop:
|
||||||
|
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
||||||
case .clear, .help, .who:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||||
case .ping: String(localized: "content.commands.ping")
|
case .ping: String(localized: "content.commands.ping")
|
||||||
case .trace: String(localized: "content.commands.trace")
|
case .trace: String(localized: "content.commands.trace")
|
||||||
|
case .drop: String(localized: "content.commands.drop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||||
// processor still allows it behind an explicit "public" confirm).
|
// processor still allows it behind an explicit "public" confirm).
|
||||||
@@ -74,11 +78,11 @@ enum CommandInfo: String, Identifiable {
|
|||||||
if !isGeoPublic {
|
if !isGeoPublic {
|
||||||
commands.append(.pay)
|
commands.append(.pay)
|
||||||
}
|
}
|
||||||
// The processor rejects favorites, groups and mesh diagnostics in
|
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
// geohash contexts, so only suggest them where they work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
|
|||||||
return messageBuffer
|
return messageBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
|
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
|
||||||
|
|
||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
|
|||||||
@@ -15,18 +15,15 @@ enum NoiseSecurityConstants {
|
|||||||
// Maximum handshake message size
|
// Maximum handshake message size
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||||
|
static let xxInitialMessageSize = 32
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
static let maxHandshakesPerMinute = 10
|
static let maxHandshakesPerMinute = 10
|
||||||
static let maxMessagesPerSecond = 100
|
static let maxMessagesPerSecond = 100
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ enum NoiseSecurityError: Error {
|
|||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
case invalidPeerID
|
case invalidPeerID
|
||||||
case rateLimitExceeded
|
case rateLimitExceeded
|
||||||
case handshakeTimeout
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Only initiator writes the first message
|
// Only initiator writes the first message
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
let message = try handshakeState!.writeMessage()
|
guard let handshake = handshakeState else {
|
||||||
|
throw NoiseSessionError.invalidState
|
||||||
|
}
|
||||||
|
let message = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(message)
|
sentHandshakeMessages.append(message)
|
||||||
return message
|
return message
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
/// A responder rehandshake must not evict a working transport session
|
||||||
private let keychain: KeychainManagerProtocol
|
/// before the candidate proves that its authenticated static key belongs
|
||||||
|
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||||
|
/// until the XX handshake completes and the binding is validated.
|
||||||
|
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -23,8 +26,6 @@ final class NoiseSessionManager {
|
|||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
self.sessionFactory = { peerID, role in
|
self.sessionFactory = { peerID, role in
|
||||||
SecureNoiseSession(
|
SecureNoiseSession(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -37,12 +38,10 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
init(
|
init(
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain _: KeychainManagerProtocol,
|
||||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||||
) {
|
) {
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
self.sessionFactory = sessionFactory
|
self.sessionFactory = sessionFactory
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -60,6 +59,9 @@ final class NoiseSessionManager {
|
|||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
}
|
}
|
||||||
|
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +70,11 @@ final class NoiseSessionManager {
|
|||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
}
|
}
|
||||||
|
for (_, candidate) in responderCandidates {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +91,7 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
@@ -97,6 +104,7 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -106,39 +114,50 @@ final class NoiseSessionManager {
|
|||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
var shouldCreateNew = false
|
let session: NoiseSession
|
||||||
var existingSession: NoiseSession? = nil
|
let isReplacementCandidate: Bool
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
if let candidate = responderCandidates[peerID] {
|
||||||
// If we have an established session, the peer must have cleared their session
|
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
// but never the established session it is trying to replace.
|
||||||
// We should accept the new handshake to re-establish encryption
|
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
if existing.isEstablished() {
|
candidate.reset()
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
responderCandidates[peerID] = replacement
|
||||||
shouldCreateNew = true
|
session = replacement
|
||||||
} else {
|
} else {
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
session = candidate
|
||||||
// reset and start fresh (the other side may have restarted)
|
}
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
isReplacementCandidate = true
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
} else if let existing = sessions[peerID] {
|
||||||
shouldCreateNew = true
|
if existing.isEstablished() {
|
||||||
} else {
|
SecureLogger.info(
|
||||||
existingSession = existing
|
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||||
}
|
category: .session
|
||||||
|
)
|
||||||
|
let candidate = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = candidate
|
||||||
|
session = candidate
|
||||||
|
isReplacementCandidate = true
|
||||||
|
} else if existing.getState() == .handshaking,
|
||||||
|
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
// No established transport state exists to preserve. A
|
||||||
|
// fresh initiation replaces the incomplete handshake.
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existing.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
sessions[peerID] = replacement
|
||||||
|
session = replacement
|
||||||
|
isReplacementCandidate = false
|
||||||
|
} else {
|
||||||
|
session = existing
|
||||||
|
isReplacementCandidate = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
isReplacementCandidate = false
|
||||||
session = existingSession!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
// Process the handshake message within the synchronized block
|
||||||
@@ -147,18 +166,40 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// Check if session is established after processing
|
// Check if session is established after processing
|
||||||
if session.isEstablished() {
|
if session.isEstablished() {
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||||
DispatchQueue.global().async { [weak self] in
|
throw NoiseSessionError.peerIdentityMismatch
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
}
|
||||||
|
|
||||||
|
if isReplacementCandidate {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
let previous = sessions.updateValue(session, forKey: peerID)
|
||||||
|
if let previous, previous !== session {
|
||||||
|
previous.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
} catch {
|
} catch {
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
// A failed candidate is discarded without touching the
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
// established session. Ordinary failed handshakes retain the
|
||||||
|
// historical cleanup behavior.
|
||||||
|
if isReplacementCandidate {
|
||||||
|
if let storedCandidate = responderCandidates[peerID],
|
||||||
|
storedCandidate === session {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
} else if let storedSession = sessions[peerID],
|
||||||
|
storedSession === session {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
session.reset()
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
DispatchQueue.global().async { [weak self] in
|
||||||
@@ -171,6 +212,24 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||||
|
/// also accepted by internal callers when they exactly match the static
|
||||||
|
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||||
|
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||||
|
private func authenticatedRemoteKey(
|
||||||
|
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
matches claimedPeerID: PeerID
|
||||||
|
) -> Bool {
|
||||||
|
let rawKey = remoteKey.rawRepresentation
|
||||||
|
if claimedPeerID.isShort {
|
||||||
|
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||||
|
}
|
||||||
|
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||||
|
return claimedNoiseKey == rawKey
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ struct NostrIdentity: Codable {
|
|||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
let npub: String // Bech32-encoded public key
|
let npub: String // Bech32-encoded public key
|
||||||
let createdAt: Date
|
|
||||||
|
|
||||||
/// Memberwise initializer
|
/// Memberwise initializer
|
||||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.npub = npub
|
self.npub = npub
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new Nostr identity
|
/// Generate a new Nostr identity
|
||||||
@@ -39,12 +37,6 @@ struct NostrIdentity: Codable {
|
|||||||
self.privateKey = privateKeyData
|
self.privateKey = privateKeyData
|
||||||
self.publicKey = xOnlyPubkey
|
self.publicKey = xOnlyPubkey
|
||||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||||
self.createdAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get signing key for event signatures
|
|
||||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
|
||||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Schnorr signing key for Nostr event signatures
|
/// Get Schnorr signing key for Nostr event signatures
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +37,6 @@ final class NostrIdentityBridge {
|
|||||||
return nostrIdentity
|
return nostrIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
/// Get Nostr public key associated with a Noise public key
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
@@ -57,29 +49,10 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
func clearAllAssociations() {
|
func clearAllAssociations() {
|
||||||
let query: [String: Any] = [
|
// Must go through the injected keychain, not raw SecItem calls:
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
// under test that keychain is in-memory, and a direct delete here
|
||||||
kSecAttrService as String: keychainService,
|
// would wipe the developer's real Nostr identity on every test run.
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
keychain.deleteAll(service: keychainService)
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache = nil
|
||||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||||
@@ -113,6 +86,13 @@ final class NostrIdentityBridge {
|
|||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
|
||||||
|
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
|
||||||
|
/// geohash-chat identity for the same cell string.
|
||||||
|
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
|
||||||
|
try deriveIdentity(forGeohash: "bridge|" + cell)
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ struct NostrProtocol {
|
|||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
|
case deletion = 5 // NIP-09 event deletion request
|
||||||
|
/// Sealed courier envelope parked on relays under its rotating
|
||||||
|
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
||||||
|
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||||
|
case courierDrop = 1401
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -255,17 +260,115 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mesh bridge (rendezvous) events
|
||||||
|
|
||||||
|
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
||||||
|
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
||||||
|
/// channel subscriptions (which filter on `#g`); `m` is
|
||||||
|
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
|
||||||
|
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
|
||||||
|
/// parsers, which key their dedup on `m[1]` unconditionally and need it
|
||||||
|
/// per-message-unique. Current parsers key bridge rows by the authenticated
|
||||||
|
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
|
||||||
|
/// coordinates are public and cannot authenticate the Nostr signer.
|
||||||
|
static func createBridgeMeshEvent(
|
||||||
|
content: String,
|
||||||
|
cell: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil,
|
||||||
|
meshSenderID: String? = nil,
|
||||||
|
meshTimestampMs: UInt64? = nil
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
var tags = [["r", cell]]
|
||||||
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
|
tags.append(["n", nickname])
|
||||||
|
}
|
||||||
|
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
|
||||||
|
let stableID = MeshMessageIdentity.stableID(
|
||||||
|
senderIDHex: meshSenderID,
|
||||||
|
timestampMs: meshTimestampMs,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
|
||||||
|
}
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: tags,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
|
||||||
|
/// cell: empty content, `r` tag only — the bridge analogue of geohash
|
||||||
|
/// presence, counted into "people across the bridge".
|
||||||
|
static func createBridgePresenceEvent(
|
||||||
|
cell: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .geohashPresence,
|
||||||
|
tags: [["r", cell]],
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
|
||||||
|
/// parked on relays. `x` is the hex recipient tag the recipient (or a
|
||||||
|
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
|
||||||
|
/// the envelope expiry so honoring relays garbage-collect the drop. The
|
||||||
|
/// signing identity should be a throwaway — the envelope authenticates
|
||||||
|
/// its sender internally via Noise-X, and linking drops to a stable
|
||||||
|
/// publisher key would leak courier traffic patterns.
|
||||||
|
static func createCourierDropEvent(
|
||||||
|
envelope: Data,
|
||||||
|
recipientTagHex: String,
|
||||||
|
expiresAt: Date,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let tags = [
|
||||||
|
["x", recipientTagHex],
|
||||||
|
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
|
||||||
|
]
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .courierDrop,
|
||||||
|
tags: tags,
|
||||||
|
content: envelope.base64EncodedString()
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
|
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
||||||
|
/// drop the note in step with a bridged board post's expiry.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil
|
nickname: String? = nil,
|
||||||
|
expiresAt: Date? = nil,
|
||||||
|
urgent: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
|
if let expiresAt {
|
||||||
|
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
||||||
|
}
|
||||||
|
if urgent {
|
||||||
|
tags.append(["t", "urgent"])
|
||||||
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -277,6 +380,24 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
||||||
|
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
||||||
|
/// key that signed the original.
|
||||||
|
static func createDeleteEvent(
|
||||||
|
ofEventID eventID: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .deletion,
|
||||||
|
tags: [["e", eventID]],
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
@@ -523,37 +644,6 @@ struct NostrProtocol {
|
|||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct version that doesn't try to add prefixes
|
|
||||||
private static func deriveSharedSecretDirect(
|
|
||||||
privateKey: P256K.Schnorr.PrivateKey,
|
|
||||||
publicKey: Data
|
|
||||||
) throws -> Data {
|
|
||||||
// Direct shared secret calculation
|
|
||||||
|
|
||||||
// Convert Schnorr private key to KeyAgreement private key
|
|
||||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
|
||||||
dataRepresentation: privateKey.dataRepresentation
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use the public key as-is (should already have prefix)
|
|
||||||
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
|
||||||
dataRepresentation: publicKey,
|
|
||||||
format: .compressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Perform ECDH
|
|
||||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
|
||||||
with: keyAgreementPublicKey,
|
|
||||||
format: .compressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert SharedSecret to Data
|
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
|
||||||
|
|
||||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
|
||||||
return sharedSecretData
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Add random offset to current time for privacy
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
// This prevents timing correlation attacks while the actual message timestamp
|
||||||
@@ -611,6 +701,10 @@ 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
|
||||||
@@ -620,6 +714,21 @@ 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()
|
||||||
|
|
||||||
@@ -683,11 +792,8 @@ struct NostrEvent: Codable {
|
|||||||
|
|
||||||
enum NostrError: Error {
|
enum NostrError: Error {
|
||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidPrivateKey
|
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case signingFailed
|
|
||||||
case encryptionFailed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
let url: String
|
let url: String
|
||||||
var isConnected: Bool = false
|
var isConnected: Bool = false
|
||||||
var lastError: Error?
|
var lastError: Error?
|
||||||
var lastConnectedAt: Date?
|
|
||||||
var messagesSent: Int = 0
|
var messagesSent: Int = 0
|
||||||
var messagesReceived: Int = 0
|
var messagesReceived: Int = 0
|
||||||
var reconnectAttempts: Int = 0
|
var reconnectAttempts: Int = 0
|
||||||
@@ -184,11 +183,26 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||||
|
|
||||||
// Track EOSE per subscription to signal when initial stored events are done
|
// Track EOSE per subscription to signal when initial stored events are
|
||||||
|
// done. Completion is scoped to relays the REQ actually reached: targets
|
||||||
|
// still mid-connect must not hold the callback hostage until the fallback
|
||||||
|
// timer (a dead relay of five used to pin "loading" for the full 10s).
|
||||||
private struct EOSETracker {
|
private struct EOSETracker {
|
||||||
var pendingRelays: Set<String>
|
/// Targets the REQ has not been delivered to yet (still connecting).
|
||||||
|
var awaitingSend: Set<String>
|
||||||
|
/// Relays that received the REQ and have not sent EOSE yet.
|
||||||
|
var awaitingEOSE: Set<String>
|
||||||
|
/// True once any relay received the REQ (or answered with EOSE) —
|
||||||
|
/// completion with zero sends would mean "done" without ever asking.
|
||||||
|
var didSend = false
|
||||||
var callback: () -> Void
|
var callback: () -> Void
|
||||||
let epoch: Int
|
let epoch: Int
|
||||||
|
|
||||||
|
/// Done when every relay that got the REQ has resolved, provided at
|
||||||
|
/// least one did — or when every target dropped out entirely.
|
||||||
|
var isComplete: Bool {
|
||||||
|
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
private var eoseTrackerEpoch = 0
|
private var eoseTrackerEpoch = 0
|
||||||
@@ -202,6 +216,15 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
|
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||||
|
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
||||||
|
/// accept/reject acknowledgment.
|
||||||
|
private struct ConfirmedSendState {
|
||||||
|
let token: UUID
|
||||||
|
var awaitingRelays: Set<String>
|
||||||
|
let completion: (Bool) -> Void
|
||||||
|
}
|
||||||
|
private var confirmedSends: [String: ConfirmedSendState] = [:]
|
||||||
// Total pending sends dropped at the queue cap; drives the sampled
|
// Total pending sends dropped at the queue cap; drives the sampled
|
||||||
// overflow warning (first + every Nth drop).
|
// overflow warning (first + every Nth drop).
|
||||||
private var pendingSendDropCount = 0
|
private var pendingSendDropCount = 0
|
||||||
@@ -298,6 +321,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for (_, tracker) in trackers {
|
for (_, tracker) in trackers {
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
}
|
}
|
||||||
|
let confirmed = confirmedSends.values.map(\.completion)
|
||||||
|
confirmedSends.removeAll()
|
||||||
|
confirmed.forEach { $0(false) }
|
||||||
pendingTorConnectionURLs.removeAll()
|
pendingTorConnectionURLs.removeAll()
|
||||||
awaitingTorForConnections = false
|
awaitingTorForConnections = false
|
||||||
torReadyWaitAttempts = 0
|
torReadyWaitAttempts = 0
|
||||||
@@ -331,6 +357,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||||
inboundEventLogCount = 0
|
inboundEventLogCount = 0
|
||||||
Self.pendingGiftWrapIDs.removeAll()
|
Self.pendingGiftWrapIDs.removeAll()
|
||||||
|
confirmedSends.removeAll()
|
||||||
|
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
messageQueue.removeAll()
|
messageQueue.removeAll()
|
||||||
@@ -347,7 +374,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
if resetState {
|
if resetState {
|
||||||
relays[index].lastError = nil
|
relays[index].lastError = nil
|
||||||
relays[index].lastConnectedAt = nil
|
|
||||||
relays[index].lastDisconnectedAt = nil
|
relays[index].lastDisconnectedAt = nil
|
||||||
relays[index].messagesSent = 0
|
relays[index].messagesSent = 0
|
||||||
relays[index].messagesReceived = 0
|
relays[index].messagesReceived = 0
|
||||||
@@ -406,6 +432,97 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts an event only on currently connected target relays and
|
||||||
|
/// reports whether at least one relay explicitly accepted it via NIP-20
|
||||||
|
/// OK. A successful WebSocket write alone is not durable acceptance.
|
||||||
|
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||||
|
/// callers use it when success unlocks durable state or user-visible
|
||||||
|
/// delivery progress.
|
||||||
|
func sendEventImmediately(
|
||||||
|
_ event: NostrEvent,
|
||||||
|
to relayUrls: [String]? = nil,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
guard dependencies.activationAllowed() else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||||
|
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
|
||||||
|
guard let connection = connectedConnection(for: relayUrl) else { return nil }
|
||||||
|
return (relayUrl, connection)
|
||||||
|
}
|
||||||
|
guard !connectedTargets.isEmpty else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = UUID()
|
||||||
|
let eventID = event.id
|
||||||
|
if let replaced = confirmedSends.removeValue(forKey: eventID) {
|
||||||
|
replaced.completion(false)
|
||||||
|
}
|
||||||
|
confirmedSends[eventID] = ConfirmedSendState(
|
||||||
|
token: token,
|
||||||
|
awaitingRelays: Set(connectedTargets.map(\.0)),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.timeoutConfirmedSend(eventID: eventID, token: token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (relayUrl, connection) in connectedTargets {
|
||||||
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
|
||||||
|
guard let self else { return }
|
||||||
|
// Success only means the bytes reached the socket; wait for
|
||||||
|
// the matching relay OK. A failed write settles this target
|
||||||
|
// as rejected because no OK can arrive for it.
|
||||||
|
if !succeeded {
|
||||||
|
self.resolveConfirmedSend(
|
||||||
|
eventID: eventID,
|
||||||
|
relayURL: relayUrl,
|
||||||
|
accepted: false,
|
||||||
|
token: token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveConfirmedSend(
|
||||||
|
eventID: String,
|
||||||
|
relayURL: String,
|
||||||
|
accepted: Bool,
|
||||||
|
token: UUID? = nil
|
||||||
|
) {
|
||||||
|
guard var state = confirmedSends[eventID],
|
||||||
|
token == nil || state.token == token,
|
||||||
|
state.awaitingRelays.remove(relayURL) != nil else { return }
|
||||||
|
if accepted {
|
||||||
|
confirmedSends.removeValue(forKey: eventID)
|
||||||
|
state.completion(true)
|
||||||
|
} else if state.awaitingRelays.isEmpty {
|
||||||
|
confirmedSends.removeValue(forKey: eventID)
|
||||||
|
state.completion(false)
|
||||||
|
} else {
|
||||||
|
confirmedSends[eventID] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeoutConfirmedSend(eventID: String, token: UUID) {
|
||||||
|
guard let state = confirmedSends[eventID], state.token == token else { return }
|
||||||
|
confirmedSends.removeValue(forKey: eventID)
|
||||||
|
state.completion(false)
|
||||||
|
}
|
||||||
|
|
||||||
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||||
@@ -795,7 +912,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||||
eoseTrackerEpoch += 1
|
eoseTrackerEpoch += 1
|
||||||
let epoch = eoseTrackerEpoch
|
let epoch = eoseTrackerEpoch
|
||||||
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
|
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||||
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -910,6 +1027,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Send initial ping to verify connection
|
// Send initial ping to verify connection
|
||||||
task.sendPing { [weak self] error in
|
task.sendPing { [weak self] error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
guard self?.connections[urlString] === task else { return }
|
||||||
if error == nil {
|
if error == nil {
|
||||||
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
||||||
self?.updateRelayStatus(urlString, isConnected: true)
|
self?.updateRelayStatus(urlString, isConnected: true)
|
||||||
@@ -919,7 +1037,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
|
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
|
||||||
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
||||||
// Trigger disconnection handler for proper backoff
|
// Trigger disconnection handler for proper backoff
|
||||||
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
|
self?.handleDisconnection(
|
||||||
|
relayUrl: urlString,
|
||||||
|
error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil),
|
||||||
|
connection: task
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -935,8 +1057,19 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
toSend[id] = state.messageString
|
toSend[id] = state.messageString
|
||||||
}
|
}
|
||||||
for (id, messageString) in toSend {
|
for (id, messageString) in toSend {
|
||||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
if self.subscriptions[relayUrl]?.contains(id) == true {
|
||||||
|
// Already subscribed on this relay (e.g. a tracker promoted
|
||||||
|
// after an earlier flush): its EOSE is coming, count it.
|
||||||
|
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
startPendingEOSETrackingIfNeeded(id: id)
|
startPendingEOSETrackingIfNeeded(id: id)
|
||||||
|
// Mark at send *initiation*, not in the async completion: a fast
|
||||||
|
// relay's EOSE could otherwise complete the tracker while this
|
||||||
|
// relay — REQ already on the wire — still sat in awaitingSend.
|
||||||
|
// If the send fails the socket is going down with it, and the
|
||||||
|
// disconnect settle (or the fallback timer) releases the wait.
|
||||||
|
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||||
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -966,18 +1099,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
guard let parsed = ParsedInbound(message) else { return }
|
guard let parsed = ParsedInbound(message) else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard self.connections[relayUrl] === task else { return }
|
||||||
self.handleParsedMessage(parsed, from: relayUrl)
|
self.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue receiving
|
// Continue receiving
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
guard self.connections[relayUrl] === task else { return }
|
||||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.handleDisconnection(relayUrl: relayUrl, error: error)
|
self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1018,8 +1153,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
case .eose(let subId):
|
case .eose(let subId):
|
||||||
if var tracker = eoseTrackers[subId] {
|
if var tracker = eoseTrackers[subId] {
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
// An EOSE proves the relay received the REQ even if the local
|
||||||
if tracker.pendingRelays.isEmpty {
|
// send completion hasn't run yet.
|
||||||
|
tracker.awaitingSend.remove(relayUrl)
|
||||||
|
tracker.awaitingEOSE.remove(relayUrl)
|
||||||
|
tracker.didSend = true
|
||||||
|
if tracker.isComplete {
|
||||||
eoseTrackers.removeValue(forKey: subId)
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1027,6 +1166,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .ok(let eventId, let success, let reason):
|
case .ok(let eventId, let success, let reason):
|
||||||
|
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
@@ -1043,7 +1183,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
private func sendToRelay(
|
||||||
|
event: NostrEvent,
|
||||||
|
connection: NostrRelayConnectionProtocol,
|
||||||
|
relayUrl: String,
|
||||||
|
completion: ((Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -1056,17 +1201,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
||||||
|
completion?(false)
|
||||||
} else {
|
} else {
|
||||||
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
||||||
// Update relay stats
|
// Update relay stats
|
||||||
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
self?.relays[index].messagesSent += 1
|
self?.relays[index].messagesSent += 1
|
||||||
}
|
}
|
||||||
|
completion?(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
||||||
|
completion?(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,7 +1223,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].isConnected = isConnected
|
relays[index].isConnected = isConnected
|
||||||
relays[index].lastError = error
|
relays[index].lastError = error
|
||||||
if isConnected {
|
if isConnected {
|
||||||
relays[index].lastConnectedAt = dependencies.now()
|
|
||||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
} else {
|
} else {
|
||||||
@@ -1100,9 +1247,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// callbacks; treat it as done and let the remaining relays (or the
|
/// callbacks; treat it as done and let the remaining relays (or the
|
||||||
/// fallback timeout) drive completion.
|
/// fallback timeout) drive completion.
|
||||||
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
||||||
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
|
for (id, var tracker) in eoseTrackers
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
|
||||||
if tracker.pendingRelays.isEmpty {
|
tracker.awaitingSend.remove(relayUrl)
|
||||||
|
tracker.awaitingEOSE.remove(relayUrl)
|
||||||
|
if tracker.isComplete {
|
||||||
eoseTrackers.removeValue(forKey: id)
|
eoseTrackers.removeValue(forKey: id)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1111,9 +1260,38 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
/// Whether any of `relayUrls` currently holds a live connection. Lets
|
||||||
|
/// subscribers distinguish "loaded, empty" from "never reached a relay"
|
||||||
|
/// when an EOSE fallback fires.
|
||||||
|
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
|
||||||
|
let targets = Set(relayUrls)
|
||||||
|
return relays.contains { targets.contains($0.url) && $0.isConnected }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
|
||||||
|
/// this relay instead of the never-connected remainder.
|
||||||
|
private func markEOSESubscribed(id: String, relayUrl: String) {
|
||||||
|
guard var tracker = eoseTrackers[id],
|
||||||
|
tracker.awaitingSend.remove(relayUrl) != nil else { return }
|
||||||
|
tracker.awaitingEOSE.insert(relayUrl)
|
||||||
|
tracker.didSend = true
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDisconnection(
|
||||||
|
relayUrl: String,
|
||||||
|
error: Error,
|
||||||
|
connection: NostrRelayConnectionProtocol? = nil
|
||||||
|
) {
|
||||||
|
if let connection, connections[relayUrl] !== connection { return }
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
|
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||||
|
state.awaitingRelays.contains(relayUrl) ? eventID : nil
|
||||||
|
}
|
||||||
|
for eventID in awaitingConfirmation {
|
||||||
|
resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false)
|
||||||
|
}
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
settleEOSETrackers(droppingRelay: relayUrl)
|
settleEOSETrackers(droppingRelay: relayUrl)
|
||||||
// If networking is disallowed, do not schedule reconnection
|
// If networking is disallowed, do not schedule reconnection
|
||||||
@@ -1302,7 +1480,7 @@ private enum ParsedInbound {
|
|||||||
case notice(String)
|
case notice(String)
|
||||||
|
|
||||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
guard let data = message.data,
|
guard let data = message.dataWithinInboundLimit,
|
||||||
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 {
|
||||||
@@ -1347,11 +1525,19 @@ private enum ParsedInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension URLSessionWebSocketTask.Message {
|
private extension URLSessionWebSocketTask.Message {
|
||||||
var data: Data? {
|
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
||||||
|
/// where we can (string length), and always before JSON parse.
|
||||||
|
var dataWithinInboundLimit: Data? {
|
||||||
|
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
||||||
switch self {
|
switch self {
|
||||||
case .string(let text): text.data(using: .utf8)
|
case .string(let text):
|
||||||
case .data(let data): data
|
guard text.utf8.count <= maxBytes else { return nil }
|
||||||
@unknown default: nil
|
return text.data(using: .utf8)
|
||||||
|
case .data(let data):
|
||||||
|
guard data.count <= maxBytes else { return nil }
|
||||||
|
return data
|
||||||
|
@unknown default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1463,6 +1649,29 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the mesh bridge: rendezvous messages (kind 20000) and presence
|
||||||
|
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
|
||||||
|
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [20000, 20001]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["r": cells]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// For courier drops: sealed envelopes (kind 1401) parked under rotating
|
||||||
|
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
|
||||||
|
// UTC days x recipients) in one filter.
|
||||||
|
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["x": recipientTagsHex]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
<string>3B52.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -28,14 +28,12 @@ struct BitchatFilePacket {
|
|||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
func encode() -> Data? {
|
||||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
|
||||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
var big = value.bigEndian
|
var big = value.bigEndian
|
||||||
@@ -68,9 +66,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
|
||||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
|
||||||
var cursor = data.startIndex
|
var cursor = data.startIndex
|
||||||
let end = data.endIndex
|
let end = data.endIndex
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ struct BitchatFilePacket {
|
|||||||
for byte in value {
|
for byte in value {
|
||||||
size = (size << 8) | UInt64(byte)
|
size = (size << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
if size > UInt64(limit) {
|
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fileSize = size
|
fileSize = size
|
||||||
@@ -139,7 +135,7 @@ struct BitchatFilePacket {
|
|||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
let proposedSize = content.count + value.count
|
let proposedSize = content.count + value.count
|
||||||
if proposedSize > limit {
|
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.append(contentsOf: value)
|
content.append(contentsOf: value)
|
||||||
@@ -149,7 +145,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
guard !content.isEmpty else { return nil }
|
||||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
return BitchatFilePacket(
|
return BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
|||||||
@@ -74,12 +74,11 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
// Private groups (0x04/0x05 reserved by other features)
|
||||||
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
|
||||||
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
|
||||||
// Private groups
|
|
||||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
|
// Live voice (push-to-talk)
|
||||||
|
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
@@ -91,10 +90,9 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
|
||||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
|
||||||
case .groupInvite: return "groupInvite"
|
case .groupInvite: return "groupInvite"
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
|
case .voiceFrame: return "voiceFrame"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
case .vouch: return "vouch"
|
||||||
@@ -132,6 +130,9 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
||||||
|
|
||||||
|
// Public live-voice burst packet (signature-verified by the transport)
|
||||||
|
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
|
||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
@@ -155,6 +156,10 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
|
||||||
|
// Default empty implementation
|
||||||
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Validates a geohash string for building-level precision (8 characters).
|
/// Validates a geohash string at any channel precision (1-12 characters).
|
||||||
/// - Parameter geohash: The geohash string to validate
|
/// - Parameter geohash: The geohash string to validate
|
||||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||||
guard geohash.count == 8 else { return false }
|
guard (1...12).contains(geohash.count) else { return false }
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// MeshMessageIdentity.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Content-derived identity for public mesh messages.
|
||||||
|
///
|
||||||
|
/// The BLE wire carries no message ID for public broadcasts, so every device
|
||||||
|
/// recomputes the same stable ID from the signed wire fields (sender ID,
|
||||||
|
/// millisecond timestamp, content). That gives the mesh bridge a
|
||||||
|
/// cross-device-consistent radio identity with zero wire change. Bridge events
|
||||||
|
/// carry this value only as a hint for detecting a radio copy that is already
|
||||||
|
/// present: sender/timestamp/content are public, so a different Nostr signer
|
||||||
|
/// can copy them and must never be allowed to reserve the genuine event's
|
||||||
|
/// authenticated dedup slot.
|
||||||
|
enum MeshMessageIdentity {
|
||||||
|
/// Matches the wire truncation in `BLEService.sendMessage`.
|
||||||
|
static func millisecondTimestamp(_ date: Date) -> UInt64 {
|
||||||
|
UInt64(date.timeIntervalSince1970 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String {
|
||||||
|
let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed
|
||||||
|
return String(Data(input.utf8).sha256Hex().prefix(32))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,14 @@ struct NostrCarrierPacket: Equatable {
|
|||||||
enum Direction: UInt8 {
|
enum Direction: UInt8 {
|
||||||
case toGateway = 0x01
|
case toGateway = 0x01
|
||||||
case fromGateway = 0x02
|
case fromGateway = 0x02
|
||||||
|
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
|
||||||
|
/// publish its signed rendezvous event. Directed, like `toGateway`.
|
||||||
|
case toBridge = 0x03
|
||||||
|
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
|
||||||
|
/// event from a remote island. Broadcast, like `fromGateway`.
|
||||||
|
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
|
||||||
|
/// carrier quietly — bridge traffic degrades to invisible, not junk.
|
||||||
|
case fromBridge = 0x04
|
||||||
}
|
}
|
||||||
|
|
||||||
let direction: Direction
|
let direction: Direction
|
||||||
|
|||||||
@@ -9,19 +9,25 @@ struct AnnouncementPacket {
|
|||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||||
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||||
|
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
|
||||||
|
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
|
||||||
|
/// tagged rendezvous events without their own location fix.
|
||||||
|
let bridgeGeohash: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
nickname: String,
|
nickname: String,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
signingPublicKey: Data,
|
signingPublicKey: Data,
|
||||||
directNeighbors: [Data]?,
|
directNeighbors: [Data]?,
|
||||||
capabilities: PeerCapabilities? = nil
|
capabilities: PeerCapabilities? = nil,
|
||||||
|
bridgeGeohash: String? = nil
|
||||||
) {
|
) {
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.signingPublicKey = signingPublicKey
|
self.signingPublicKey = signingPublicKey
|
||||||
self.directNeighbors = directNeighbors
|
self.directNeighbors = directNeighbors
|
||||||
self.capabilities = capabilities
|
self.capabilities = capabilities
|
||||||
|
self.bridgeGeohash = bridgeGeohash
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
@@ -30,6 +36,7 @@ struct AnnouncementPacket {
|
|||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
case directNeighbors = 0x04
|
||||||
case capabilities = 0x05
|
case capabilities = 0x05
|
||||||
|
case bridgeGeohash = 0x06
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -74,6 +81,15 @@ struct AnnouncementPacket {
|
|||||||
data.append(capabilityBytes)
|
data.append(capabilityBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLV for bridge rendezvous cell (optional; old clients skip it)
|
||||||
|
if let bridgeGeohash = bridgeGeohash,
|
||||||
|
let cellData = bridgeGeohash.data(using: .utf8),
|
||||||
|
!cellData.isEmpty, cellData.count <= 12 {
|
||||||
|
data.append(TLVType.bridgeGeohash.rawValue)
|
||||||
|
data.append(UInt8(cellData.count))
|
||||||
|
data.append(cellData)
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +100,7 @@ struct AnnouncementPacket {
|
|||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
var capabilities: PeerCapabilities?
|
var capabilities: PeerCapabilities?
|
||||||
|
var bridgeGeohash: String?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -116,6 +133,10 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
case .capabilities:
|
case .capabilities:
|
||||||
capabilities = PeerCapabilities(encoded: Data(value))
|
capabilities = PeerCapabilities(encoded: Data(value))
|
||||||
|
case .bridgeGeohash:
|
||||||
|
if length <= 12 {
|
||||||
|
bridgeGeohash = String(data: value, encoding: .utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -129,7 +150,8 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors,
|
directNeighbors: directNeighbors,
|
||||||
capabilities: capabilities
|
capabilities: capabilities,
|
||||||
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = {
|
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
|
||||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
|
||||||
return caps
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// VoiceBurstPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Audio codec of a live voice burst. START packets carry it so receivers can
|
||||||
|
/// reject bursts they can't decode instead of feeding garbage to the decoder.
|
||||||
|
enum VoiceBurstCodec: UInt8 {
|
||||||
|
/// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so
|
||||||
|
/// the finalized `.m4a` and the live frames come from the same encoder
|
||||||
|
/// settings.
|
||||||
|
case aacLC16kMono = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One packet of a live push-to-talk voice burst (the inner payload of
|
||||||
|
/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload
|
||||||
|
/// of `MessageType.voiceFrame`).
|
||||||
|
///
|
||||||
|
/// Wire format:
|
||||||
|
/// ```
|
||||||
|
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…]
|
||||||
|
/// ```
|
||||||
|
/// - flags 0x01 (START): payload = [codec: UInt8]
|
||||||
|
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
|
||||||
|
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
|
||||||
|
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
|
||||||
|
struct VoiceBurstPacket: Equatable {
|
||||||
|
enum Kind: Equatable {
|
||||||
|
case start(codec: VoiceBurstCodec)
|
||||||
|
case frames([Data])
|
||||||
|
case end(totalDataPackets: UInt16, durationMs: UInt32)
|
||||||
|
case canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
static let burstIDSize = 8
|
||||||
|
private static let headerSize = burstIDSize + 2 + 1
|
||||||
|
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
|
||||||
|
static let maxFramesPerPacket = 8
|
||||||
|
|
||||||
|
private enum Flags {
|
||||||
|
static let start: UInt8 = 0x01
|
||||||
|
static let end: UInt8 = 0x02
|
||||||
|
static let canceled: UInt8 = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
let burstID: Data
|
||||||
|
let seq: UInt16
|
||||||
|
let kind: Kind
|
||||||
|
|
||||||
|
init?(burstID: Data, seq: UInt16, kind: Kind) {
|
||||||
|
guard burstID.count == Self.burstIDSize else { return nil }
|
||||||
|
if case .frames(let frames) = kind {
|
||||||
|
guard !frames.isEmpty,
|
||||||
|
frames.count <= Self.maxFramesPerPacket,
|
||||||
|
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
|
||||||
|
else { return nil }
|
||||||
|
}
|
||||||
|
self.burstID = burstID
|
||||||
|
self.seq = seq
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var data = Data(capacity: Self.headerSize + payloadSize)
|
||||||
|
data.append(burstID)
|
||||||
|
data.append(UInt8((seq >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(seq & 0xFF))
|
||||||
|
switch kind {
|
||||||
|
case .start(let codec):
|
||||||
|
data.append(Flags.start)
|
||||||
|
data.append(codec.rawValue)
|
||||||
|
case .frames(let frames):
|
||||||
|
data.append(0)
|
||||||
|
for frame in frames {
|
||||||
|
let length = UInt16(frame.count)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
data.append(frame)
|
||||||
|
}
|
||||||
|
case .end(let totalDataPackets, let durationMs):
|
||||||
|
data.append(Flags.end)
|
||||||
|
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(totalDataPackets & 0xFF))
|
||||||
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
case .canceled:
|
||||||
|
data.append(Flags.canceled)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> VoiceBurstPacket? {
|
||||||
|
// Work on a re-based copy so subscripting is offset-safe.
|
||||||
|
let data = Data(data)
|
||||||
|
guard data.count >= headerSize else { return nil }
|
||||||
|
|
||||||
|
let burstID = data.prefix(burstIDSize)
|
||||||
|
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
|
||||||
|
let flags = data[burstIDSize + 2]
|
||||||
|
let payload = data.dropFirst(headerSize)
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
switch flags {
|
||||||
|
case Flags.start:
|
||||||
|
guard let codecByte = payload.first,
|
||||||
|
let codec = VoiceBurstCodec(rawValue: codecByte)
|
||||||
|
else { return nil }
|
||||||
|
kind = .start(codec: codec)
|
||||||
|
case Flags.end:
|
||||||
|
guard payload.count >= 6 else { return nil }
|
||||||
|
let bytes = Array(payload)
|
||||||
|
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
|
||||||
|
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||||
|
kind = .end(totalDataPackets: total, durationMs: duration)
|
||||||
|
case Flags.canceled:
|
||||||
|
kind = .canceled
|
||||||
|
case 0:
|
||||||
|
var frames: [Data] = []
|
||||||
|
var offset = payload.startIndex
|
||||||
|
while offset < payload.endIndex {
|
||||||
|
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
|
||||||
|
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
|
||||||
|
offset = payload.index(offset, offsetBy: 2)
|
||||||
|
guard length > 0,
|
||||||
|
payload.distance(from: offset, to: payload.endIndex) >= length,
|
||||||
|
frames.count < maxFramesPerPacket
|
||||||
|
else { return nil }
|
||||||
|
let end = payload.index(offset, offsetBy: length)
|
||||||
|
frames.append(Data(payload[offset..<end]))
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
guard !frames.isEmpty else { return nil }
|
||||||
|
kind = .frames(frames)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeBurstID() -> Data {
|
||||||
|
var bytes = Data(count: burstIDSize)
|
||||||
|
let result = bytes.withUnsafeMutableBytes {
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
|
||||||
|
}
|
||||||
|
guard result == errSecSuccess else {
|
||||||
|
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private var payloadSize: Int {
|
||||||
|
switch kind {
|
||||||
|
case .start: return 1
|
||||||
|
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
|
||||||
|
case .end: return 6
|
||||||
|
case .canceled: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy packetizer for outgoing bursts: batches encoded frames into
|
||||||
|
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
|
||||||
|
/// packet in a single BLE frame after Noise encryption and padding.
|
||||||
|
/// Not thread-safe — confine to one queue.
|
||||||
|
struct VoiceBurstPacketizer {
|
||||||
|
let burstID: Data
|
||||||
|
private let budget: Int
|
||||||
|
private var pendingFrames: [Data] = []
|
||||||
|
private var pendingSize = 0
|
||||||
|
/// seq 0 is reserved for START; data packets start at 1.
|
||||||
|
private(set) var nextSeq: UInt16 = 1
|
||||||
|
private(set) var dataPacketCount: UInt16 = 0
|
||||||
|
|
||||||
|
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
|
||||||
|
self.burstID = burstID
|
||||||
|
self.budget = budget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one encoded frame, returning any packets that became full.
|
||||||
|
/// Frames larger than the budget are dropped (the encoder's ~130-byte
|
||||||
|
/// frames never hit this; it guards against misconfiguration looping).
|
||||||
|
mutating func add(_ frame: Data) -> [Data] {
|
||||||
|
let frameCost = 2 + frame.count
|
||||||
|
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
|
||||||
|
|
||||||
|
var packets: [Data] = []
|
||||||
|
if !pendingFrames.isEmpty,
|
||||||
|
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|
||||||
|
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
|
||||||
|
packets.append(contentsOf: flush())
|
||||||
|
}
|
||||||
|
pendingFrames.append(frame)
|
||||||
|
pendingSize += frameCost
|
||||||
|
return packets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits any buffered frames as a final data packet.
|
||||||
|
mutating func flush() -> [Data] {
|
||||||
|
guard !pendingFrames.isEmpty,
|
||||||
|
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
|
||||||
|
else {
|
||||||
|
pendingFrames = []
|
||||||
|
pendingSize = 0
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
pendingFrames = []
|
||||||
|
pendingSize = 0
|
||||||
|
nextSeq &+= 1
|
||||||
|
dataPacketCount &+= 1
|
||||||
|
return [packet.encode()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,6 @@ import Foundation
|
|||||||
/// Manages autocomplete functionality for chat
|
/// Manages autocomplete functionality for chat
|
||||||
final class AutocompleteService {
|
final class AutocompleteService {
|
||||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
|
||||||
|
|
||||||
private let commands = [
|
|
||||||
"/msg", "/who", "/clear",
|
|
||||||
"/hug", "/slap", "/fav", "/unfav",
|
|
||||||
"/block", "/unblock"
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Get autocomplete suggestions for current text
|
/// Get autocomplete suggestions for current text
|
||||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||||
@@ -73,26 +66,6 @@ final class AutocompleteService {
|
|||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
|
||||||
guard let regex = commandRegex else { return nil }
|
|
||||||
|
|
||||||
let nsText = text as NSString
|
|
||||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
|
||||||
|
|
||||||
guard let match = matches.last else { return nil }
|
|
||||||
|
|
||||||
let fullRange = match.range(at: 0)
|
|
||||||
let captureRange = match.range(at: 1)
|
|
||||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
|
||||||
|
|
||||||
let suggestions = commands
|
|
||||||
.filter { $0.hasPrefix("/\(prefix)") }
|
|
||||||
.sorted()
|
|
||||||
.prefix(5)
|
|
||||||
|
|
||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func needsArgument(command: String) -> Bool {
|
private func needsArgument(command: String) -> Bool {
|
||||||
switch command {
|
switch command {
|
||||||
case "/who", "/clear":
|
case "/who", "/clear":
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
||||||
|
/// Whether the link this packet arrived on is already bound to a
|
||||||
|
/// different peer ID (ingress-registry + BLE-queue read). Directness
|
||||||
|
/// rides on the unsigned TTL, so a replayed announce can look "direct"
|
||||||
|
/// on the replayer's link; that link must not shortcut an absent peer
|
||||||
|
/// into "connected".
|
||||||
|
let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool
|
||||||
/// Runs the registry mutation phase under the collections barrier.
|
/// Runs the registry mutation phase under the collections barrier.
|
||||||
let withRegistryBarrier: (() -> Void) -> Void
|
let withRegistryBarrier: (() -> Void) -> Void
|
||||||
/// Upserts the verified announce into the peer registry.
|
/// Upserts the verified announce into the peer registry.
|
||||||
@@ -135,6 +141,23 @@ final class BLEAnnounceHandler {
|
|||||||
var isReconnectedPeer = false
|
var isReconnectedPeer = false
|
||||||
let directLinkState = env.linkState(peerID)
|
let directLinkState = env.linkState(peerID)
|
||||||
let isDirectAnnounce = packet.ttl == env.messageTTL
|
let isDirectAnnounce = packet.ttl == env.messageTTL
|
||||||
|
// A "direct" announce arriving on a link that another peer already
|
||||||
|
// owns is either a rotation heal or a replay with its TTL restored;
|
||||||
|
// both are ambiguous, so only the rebind (which containment-checks
|
||||||
|
// the claimed identity) may promote it — never this shortcut.
|
||||||
|
//
|
||||||
|
// Known limitation: denying the shortcut cannot prevent forged
|
||||||
|
// presence outright. A rebind that passes the containment checks
|
||||||
|
// promotes the claimed peer to connected — it must, or a legitimate
|
||||||
|
// rotation on an open link would read as disconnected — so a replay
|
||||||
|
// that wins the rebind (absent victim, cooldown clear) still forges
|
||||||
|
// presence. That residue is presence display only: DMs stay gated on
|
||||||
|
// canDeliverSecurely (no Noise session means retain + courier, see
|
||||||
|
// MessageRouter.sendPrivate). What this check buys: the ambiguous
|
||||||
|
// announce alone never flips presence — forging requires winning the
|
||||||
|
// containment-checked rebind (never steals an identity that owns a
|
||||||
|
// live link; at most one rebind per link per cooldown window).
|
||||||
|
let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID)
|
||||||
|
|
||||||
env.withRegistryBarrier {
|
env.withRegistryBarrier {
|
||||||
let hasPeripheralConnection = directLinkState.hasPeripheral
|
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||||
@@ -152,7 +175,7 @@ final class BLEAnnounceHandler {
|
|||||||
let update = env.upsertVerifiedAnnounce(
|
let update = env.upsertVerifiedAnnounce(
|
||||||
peerID,
|
peerID,
|
||||||
announcement,
|
announcement,
|
||||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
isNewPeer = update.isNewPeer
|
isNewPeer = update.isNewPeer
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ enum BLEFanoutSelector {
|
|||||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||||
peripheralPeerBindings: [String: PeerID] = [:],
|
peripheralPeerBindings: [String: PeerID] = [:],
|
||||||
centralPeerBindings: [String: PeerID] = [:],
|
centralPeerBindings: [String: PeerID] = [:],
|
||||||
|
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
||||||
|
collapseDuplicatePeerLinks: Bool = true,
|
||||||
directedPeerHint: PeerID?,
|
directedPeerHint: PeerID?,
|
||||||
|
requireDirectPeerLink: Bool = false,
|
||||||
packetType: UInt8,
|
packetType: UInt8,
|
||||||
messageID: String
|
messageID: String
|
||||||
) -> BLEFanoutSelection {
|
) -> BLEFanoutSelection {
|
||||||
@@ -31,10 +34,14 @@ enum BLEFanoutSelector {
|
|||||||
to: directedPeerHint,
|
to: directedPeerHint,
|
||||||
links: rawAllowed,
|
links: rawAllowed,
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
centralPeerBindings: centralPeerBindings
|
centralPeerBindings: centralPeerBindings,
|
||||||
|
preferredPeripheralPerPeer: preferredPeripheralPerPeer
|
||||||
) {
|
) {
|
||||||
return directedSelection
|
return directedSelection
|
||||||
}
|
}
|
||||||
|
if directedPeerHint != nil, requireDirectPeerLink {
|
||||||
|
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||||
|
}
|
||||||
if let directedPeerHint,
|
if let directedPeerHint,
|
||||||
hasBoundLink(
|
hasBoundLink(
|
||||||
to: directedPeerHint,
|
to: directedPeerHint,
|
||||||
@@ -46,11 +53,20 @@ enum BLEFanoutSelector {
|
|||||||
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowed = collapseDuplicateLinksPerPeer(
|
// Direct announces are the packet that binds a link to its peer
|
||||||
rawAllowed,
|
// (BLEService's raw bind and verified rebind). Collapsing them per
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
// peer starves duplicate same-peer links of the announce they need to
|
||||||
centralPeerBindings: centralPeerBindings
|
// become bound — the duplicates then look "pre-announce" forever and
|
||||||
)
|
// every broadcast sprays down all of them. Announces are small and
|
||||||
|
// throttled, so they go on every live link.
|
||||||
|
let allowed = collapseDuplicatePeerLinks
|
||||||
|
? collapseDuplicateLinksPerPeer(
|
||||||
|
rawAllowed,
|
||||||
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
|
centralPeerBindings: centralPeerBindings,
|
||||||
|
preferredPeripheralPerPeer: preferredPeripheralPerPeer
|
||||||
|
)
|
||||||
|
: rawAllowed
|
||||||
|
|
||||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||||
return BLEFanoutSelection(
|
return BLEFanoutSelection(
|
||||||
@@ -97,7 +113,8 @@ enum BLEFanoutSelector {
|
|||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
links: (peripheralIDs: [String], centralIDs: [String]),
|
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||||
peripheralPeerBindings: [String: PeerID],
|
peripheralPeerBindings: [String: PeerID],
|
||||||
centralPeerBindings: [String: PeerID]
|
centralPeerBindings: [String: PeerID],
|
||||||
|
preferredPeripheralPerPeer: [PeerID: String]
|
||||||
) -> BLEFanoutSelection? {
|
) -> BLEFanoutSelection? {
|
||||||
let directLinks = collapseDuplicateLinksPerPeer(
|
let directLinks = collapseDuplicateLinksPerPeer(
|
||||||
(
|
(
|
||||||
@@ -105,7 +122,8 @@ enum BLEFanoutSelector {
|
|||||||
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||||
),
|
),
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
centralPeerBindings: centralPeerBindings
|
centralPeerBindings: centralPeerBindings,
|
||||||
|
preferredPeripheralPerPeer: preferredPeripheralPerPeer
|
||||||
)
|
)
|
||||||
|
|
||||||
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||||
@@ -140,7 +158,8 @@ enum BLEFanoutSelector {
|
|||||||
private static func collapseDuplicateLinksPerPeer(
|
private static func collapseDuplicateLinksPerPeer(
|
||||||
_ links: (peripheralIDs: [String], centralIDs: [String]),
|
_ links: (peripheralIDs: [String], centralIDs: [String]),
|
||||||
peripheralPeerBindings: [String: PeerID],
|
peripheralPeerBindings: [String: PeerID],
|
||||||
centralPeerBindings: [String: PeerID]
|
centralPeerBindings: [String: PeerID],
|
||||||
|
preferredPeripheralPerPeer: [PeerID: String]
|
||||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||||
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
|
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
|
||||||
return links
|
return links
|
||||||
@@ -148,13 +167,30 @@ enum BLEFanoutSelector {
|
|||||||
|
|
||||||
var seenPeers = Set<PeerID>()
|
var seenPeers = Set<PeerID>()
|
||||||
var keptPeripheralIDs: [String] = []
|
var keptPeripheralIDs: [String] = []
|
||||||
|
// When a peer has several bound peripheral links (duplicate
|
||||||
|
// connections after a restore), collapse onto its preferred one (the
|
||||||
|
// most recently bound) instead of dictionary order — an arbitrary
|
||||||
|
// pick could route a peer's single collapsed copy down a stale link.
|
||||||
for id in links.peripheralIDs {
|
for id in links.peripheralIDs {
|
||||||
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
guard let peer = peripheralPeerBindings[id],
|
||||||
continue
|
preferredPeripheralPerPeer[peer] == id,
|
||||||
|
seenPeers.insert(peer).inserted else { continue }
|
||||||
|
keptPeripheralIDs.append(id)
|
||||||
|
}
|
||||||
|
for id in links.peripheralIDs {
|
||||||
|
if let peer = peripheralPeerBindings[id] {
|
||||||
|
if preferredPeripheralPerPeer[peer] == id { continue }
|
||||||
|
if !seenPeers.insert(peer).inserted { continue }
|
||||||
}
|
}
|
||||||
keptPeripheralIDs.append(id)
|
keptPeripheralIDs.append(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Known limitation: centrals collapse in subscription order (oldest
|
||||||
|
// first) — there is no recency signal like the peripheral reverse
|
||||||
|
// map. A central-only peer with duplicate subscriptions rides the
|
||||||
|
// oldest one until the remote side (which owns those connections)
|
||||||
|
// consolidates on its next verified announce (bounded by its
|
||||||
|
// retirement cooldown).
|
||||||
var keptCentralIDs: [String] = []
|
var keptCentralIDs: [String] = []
|
||||||
for id in links.centralIDs {
|
for id in links.centralIDs {
|
||||||
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let localNickname: () -> String
|
let localNickname: () -> String
|
||||||
/// Snapshot of known peers keyed by ID (registry read).
|
/// Snapshot of known peers keyed by ID (registry read).
|
||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
|
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||||
|
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
/// Tracks the broadcast file packet for gossip sync.
|
||||||
@@ -44,49 +46,54 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
/// Returns `false` when the packet fails sender authentication and must
|
||||||
/// pass the ceiling that was enforced against the accepted offer.
|
/// not be relayed onward. Every other outcome returns `true`: files
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
/// directed to another peer are forwarded untouched, and local-only drops
|
||||||
|
/// (malformed payload, quota, save failure) don't affect multi-hop
|
||||||
|
/// delivery to nodes that may handle them fine.
|
||||||
|
@discardableResult
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peersSnapshot,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
|
||||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let peersSnapshot = env.peersSnapshot()
|
||||||
|
guard let senderNickname = resolveSenderNickname(
|
||||||
|
packet: packet,
|
||||||
|
from: peerID,
|
||||||
|
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||||
|
peers: peersSnapshot,
|
||||||
|
env: env
|
||||||
|
) else {
|
||||||
|
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if deliveryPlan.shouldTrackForSync {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
case .failure(.malformedPayload):
|
case .failure(.malformedPayload):
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return
|
return true
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
return
|
return true
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return
|
return true
|
||||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -99,7 +106,7 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if deliveryPlan.isPrivateMessage {
|
||||||
@@ -115,11 +122,66 @@ final class BLEFileTransferHandler {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID,
|
||||||
|
// Received messages need an explicit status: BitchatMessage
|
||||||
|
// defaults private messages to .sending, which the media views
|
||||||
|
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||||
|
deliveryStatus: deliveryPlan.isPrivateMessage
|
||||||
|
? .delivered(to: env.localNickname(), at: ts)
|
||||||
|
: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
env.deliverMessage(message)
|
env.deliverMessage(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the authenticated display name for a file transfer's sender.
|
||||||
|
///
|
||||||
|
/// Directed (private) transfers are addressed to us specifically and keep
|
||||||
|
/// the lenient connected-peer path. Broadcast transfers carry an
|
||||||
|
/// attacker-controllable `senderID` exactly like public messages and public
|
||||||
|
/// voice frames — registry membership alone is NOT proof of identity, so a
|
||||||
|
/// valid packet signature from the claimed sender is required before we
|
||||||
|
/// trust it. Without this, a peer that observed a public voice burst could
|
||||||
|
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
||||||
|
/// overwrite the signature-verified live bubble with attacker audio.
|
||||||
|
private func resolveSenderNickname(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
isBroadcast: Bool,
|
||||||
|
peers: [PeerID: BLEPeerInfo],
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> String? {
|
||||||
|
guard isBroadcast else {
|
||||||
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: true
|
||||||
|
) ?? env.signedSenderDisplayName(packet, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
||||||
|
// trivially authentic and cannot be verified against the peer registry
|
||||||
|
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
||||||
|
// does. Verify against the signing key already in the
|
||||||
|
// (synchronously-updated) registry first, then fall back to the
|
||||||
|
// persisted-identity signature lookup for peers not yet cached there.
|
||||||
|
let isSelf = peerID == env.localPeerID()
|
||||||
|
let registrySigningKey = peers[peerID]?.signingPublicKey
|
||||||
|
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||||
|
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||||
|
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
||||||
|
|
||||||
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) ?? signedDisplayName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,17 +42,12 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
enum BLEIncomingFileValidator {
|
||||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
/// pass the ceiling enforced against the accepted offer.
|
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||||
static func validate(
|
|
||||||
payload: Data,
|
|
||||||
limit: Int = FileTransferLimits.maxPayloadBytes
|
|
||||||
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
|
||||||
return .failure(.malformedPayload)
|
return .failure(.malformedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct Metadata {
|
private struct Metadata {
|
||||||
let type: UInt8
|
|
||||||
let total: Int
|
let total: Int
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
let isBroadcast: Bool
|
let isBroadcast: Bool
|
||||||
@@ -150,7 +149,6 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
fragmentsByKey[header.key] = [:]
|
||||||
metadataByKey[header.key] = Metadata(
|
metadataByKey[header.key] = Metadata(
|
||||||
type: header.originalType,
|
|
||||||
total: header.total,
|
total: header.total,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
isBroadcast: header.isBroadcastFragment,
|
isBroadcast: header.isBroadcastFragment,
|
||||||
|
|||||||
@@ -33,13 +33,21 @@ final class BLEFragmentHandler {
|
|||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Don't process our own fragments
|
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||||
|
|
||||||
|
// Sync replay legitimately hands us our own fragments back (the RSR
|
||||||
|
// ttl=0 restore path): after a relaunch the fragment store starts
|
||||||
|
// empty, so our sync filter doesn't cover them and peers re-offer
|
||||||
|
// them. Record them as seen — the next round's filter then covers
|
||||||
|
// them and the redelivery stops — but skip assembly: we authored
|
||||||
|
// the original, there is nothing to reassemble.
|
||||||
if peerID == env.localPeerID() {
|
if peerID == env.localPeerID() {
|
||||||
|
if header.isBroadcastFragment {
|
||||||
|
env.trackPacketSeen(packet)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
|
||||||
|
|
||||||
if header.isBroadcastFragment {
|
if header.isBroadcastFragment {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,235 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct PanicRecoveryIntent {
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
let fileMarkerEstablished: Bool
|
||||||
|
let externalMarkerEstablished: Bool
|
||||||
|
|
||||||
private let fileManager: FileManager
|
var hasDurableMarker: Bool {
|
||||||
|
fileMarkerEstablished || externalMarkerEstablished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small, dependency-injectable transaction surface used by ChatViewModel.
|
||||||
|
/// Production persists the same intent in two independent locations before
|
||||||
|
/// any application state is erased. Tests can inject an ephemeral operation
|
||||||
|
/// set without touching the developer's Application Support directory.
|
||||||
|
struct PanicRecoveryOperations {
|
||||||
|
let isPending: () throws -> Bool
|
||||||
|
let begin: () -> PanicRecoveryIntent
|
||||||
|
let wipeMedia: (PanicRecoveryIntent) throws -> Void
|
||||||
|
let complete: () throws -> Void
|
||||||
|
|
||||||
|
static func ephemeral(
|
||||||
|
wipeMedia: @escaping () throws -> Void = {}
|
||||||
|
) -> PanicRecoveryOperations {
|
||||||
|
PanicRecoveryOperations(
|
||||||
|
isPending: { false },
|
||||||
|
begin: {
|
||||||
|
PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: false,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in try wipeMedia() },
|
||||||
|
complete: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func live(
|
||||||
|
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||||
|
defaults: UserDefaults = .standard
|
||||||
|
) -> PanicRecoveryOperations {
|
||||||
|
let defaultsKey = "bitchat.panicResetPending"
|
||||||
|
return PanicRecoveryOperations(
|
||||||
|
isPending: {
|
||||||
|
if defaults.bool(forKey: defaultsKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return try fileStore.isPanicRecoveryPending()
|
||||||
|
},
|
||||||
|
begin: {
|
||||||
|
defaults.set(true, forKey: defaultsKey)
|
||||||
|
let externalMarkerEstablished =
|
||||||
|
defaults.synchronize()
|
||||||
|
&& defaults.bool(forKey: defaultsKey)
|
||||||
|
|
||||||
|
let fileMarkerEstablished: Bool
|
||||||
|
do {
|
||||||
|
try fileStore.markPanicRecoveryPending()
|
||||||
|
fileMarkerEstablished = true
|
||||||
|
} catch {
|
||||||
|
fileMarkerEstablished = false
|
||||||
|
SecureLogger.error(
|
||||||
|
"Failed to persist file panic-recovery marker: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: fileMarkerEstablished,
|
||||||
|
externalMarkerEstablished: externalMarkerEstablished
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { intent in
|
||||||
|
try fileStore.panicWipe(
|
||||||
|
hasDurablePendingMarker: intent.hasDurableMarker
|
||||||
|
)
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
// Keep the independent defaults latch until the file marker
|
||||||
|
// has definitely cleared. Any failure therefore remains
|
||||||
|
// visible to the next launch.
|
||||||
|
try fileStore.completePanicRecovery()
|
||||||
|
defaults.removeObject(forKey: defaultsKey)
|
||||||
|
guard defaults.synchronize(),
|
||||||
|
!defaults.bool(forKey: defaultsKey) else {
|
||||||
|
throw BLEIncomingFileStore.PanicRecoveryError
|
||||||
|
.externalMarkerCommitFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIncomingFileStore {
|
||||||
|
enum PanicRecoveryError: Error {
|
||||||
|
case externalMarkerCommitFailed
|
||||||
|
case markerWriteFailed(Error)
|
||||||
|
case markerWriteAndMediaWipeFailed(
|
||||||
|
markerError: Error,
|
||||||
|
mediaError: Error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
|
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||||
|
/// fail-closed startup decision before the full panic has committed.
|
||||||
|
private static let panicRecoveryPendingMarkerFileName =
|
||||||
|
".panic-recovery-pending"
|
||||||
|
/// Compatibility with a short-lived development build that used the
|
||||||
|
/// media-specific name for the same full-transaction latch.
|
||||||
|
private static let legacyPanicRecoveryPendingMarkerFileName =
|
||||||
|
".panic-media-wipe-pending"
|
||||||
|
private static let mediaSubdirectories = [
|
||||||
|
"voicenotes/incoming",
|
||||||
|
"voicenotes/outgoing",
|
||||||
|
"images/incoming",
|
||||||
|
"images/outgoing",
|
||||||
|
"files/incoming",
|
||||||
|
"files/outgoing"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
|
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||||
|
/// and kills playback — and the coordinator's startup sweep deletes any
|
||||||
|
/// orphans a previous session left behind.
|
||||||
|
static let liveCapturePrefix = "voice_live_"
|
||||||
|
|
||||||
|
/// Exposed so callers that write progressively into the store's
|
||||||
|
/// directories (live voice captures) share the same file manager.
|
||||||
|
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(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
init(
|
||||||
|
fileManager: FileManager = .default,
|
||||||
|
baseDirectory: URL? = nil,
|
||||||
|
dateProvider: @escaping () -> Date = Date.init,
|
||||||
|
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||||
|
try $0.write(to: $1, options: .atomic)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.fileManager = fileManager
|
self.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
|
||||||
|
/// write progressively instead of via `save` (live voice captures).
|
||||||
|
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||||
|
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(
|
func save(
|
||||||
@@ -39,6 +257,11 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||||
|
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||||
|
/// captures) are never evicted regardless of who triggers enforcement —
|
||||||
|
/// a finalized transfer can arrive at quota while a burst is still
|
||||||
|
/// streaming — but they still count toward usage.
|
||||||
func enforceQuota(reservingBytes: Int) {
|
func enforceQuota(reservingBytes: Int) {
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory()
|
let base = try filesDirectory()
|
||||||
@@ -72,6 +295,7 @@ struct BLEIncomingFileStore {
|
|||||||
var freedSpace: Int64 = 0
|
var freedSpace: Int64 = 0
|
||||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
guard freedSpace < needToFree else { break }
|
guard freedSpace < needToFree else { break }
|
||||||
|
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: file.url)
|
try fileManager.removeItem(at: file.url)
|
||||||
freedSpace += file.size
|
freedSpace += file.size
|
||||||
@@ -90,15 +314,39 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
private func filesDirectory() throws -> URL {
|
||||||
let root = try baseDirectory ?? fileManager.url(
|
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rootDirectory() throws -> URL {
|
||||||
|
try baseDirectory ?? fileManager.url(
|
||||||
for: .applicationSupportDirectory,
|
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)
|
|
||||||
return filesDir
|
private func panicRecoveryPendingMarkerURL() throws -> URL {
|
||||||
|
try rootDirectory().appendingPathComponent(
|
||||||
|
Self.panicRecoveryPendingMarkerFileName,
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func panicRecoveryMarkerURLs() throws -> [URL] {
|
||||||
|
let root = try rootDirectory()
|
||||||
|
return [
|
||||||
|
root.appendingPathComponent(
|
||||||
|
Self.panicRecoveryPendingMarkerFileName,
|
||||||
|
isDirectory: false
|
||||||
|
),
|
||||||
|
root.appendingPathComponent(
|
||||||
|
Self.legacyPanicRecoveryPendingMarkerFileName,
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
|||||||
@@ -78,10 +78,21 @@ struct BLEIngressLinkRegistry {
|
|||||||
return .failure(.selfLoopback(packetType: packet.type))
|
return .failure(.selfLoopback(packetType: packet.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let boundPeerID,
|
if let boundPeerID, boundPeerID != claimedSenderID {
|
||||||
boundPeerID != claimedSenderID,
|
if requiresDirectSenderBinding(packet) {
|
||||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
}
|
||||||
|
// A direct announce claiming a new sender on a bound link is either
|
||||||
|
// a spoof or a legitimate peer-ID rotation on a connection that
|
||||||
|
// outlived the old ID. Attribute it to the claimed sender and let
|
||||||
|
// it through: announces are self-authenticating, and only a
|
||||||
|
// signature-verified announce may rebind the link (BLEService).
|
||||||
|
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||||
|
return .success(BLEIngressPacketContext(
|
||||||
|
receivedFromPeerID: claimedSenderID,
|
||||||
|
validationPeerID: claimedSenderID
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||||
@@ -98,12 +109,15 @@ struct BLEIngressLinkRegistry {
|
|||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
|
||||||
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
||||||
// must be the link peer — it elicits a full store replay, and the
|
// must be the link peer — it elicits a full store replay, and the
|
||||||
// response is addressed to whoever the sender claims to be.
|
// response is addressed to whoever the sender claims to be.
|
||||||
if packet.type == MessageType.requestSync.rawValue { return true }
|
packet.type == MessageType.requestSync.rawValue
|
||||||
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
}
|
||||||
|
|
||||||
|
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||||
|
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||||
|
|||||||
@@ -164,7 +164,11 @@ final class BLELinkStateStore {
|
|||||||
guard let peerID else { return [] }
|
guard let peerID else { return [] }
|
||||||
|
|
||||||
var links: Set<BLEIngressLinkID> = []
|
var links: Set<BLEIngressLinkID> = []
|
||||||
if let peripheralUUID = peerToPeripheralUUID[peerID] {
|
// Scan all states rather than the 1:1 reverse map: after a state
|
||||||
|
// restoration the same device can hold several live peripheral links
|
||||||
|
// bound to one peer (it reappears under a fresh UUID while the
|
||||||
|
// restored connection lives on).
|
||||||
|
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
|
||||||
links.insert(.peripheral(peripheralUUID))
|
links.insert(.peripheral(peripheralUUID))
|
||||||
}
|
}
|
||||||
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||||
@@ -173,6 +177,13 @@ final class BLELinkStateStore {
|
|||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The peer's most recently bound peripheral link, per peer. Used to keep
|
||||||
|
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
|
||||||
|
var preferredPeripheralBindings: [PeerID: String] {
|
||||||
|
assertOwned()
|
||||||
|
return peerToPeripheralUUID
|
||||||
|
}
|
||||||
|
|
||||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||||
assertOwned()
|
assertOwned()
|
||||||
return peripherals[peripheralID]?.peerID
|
return peripherals[peripheralID]?.peerID
|
||||||
@@ -203,16 +214,37 @@ final class BLELinkStateStore {
|
|||||||
|
|
||||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||||
assertOwned()
|
assertOwned()
|
||||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
var previousPeerID: PeerID?
|
||||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
let updated = updatePeripheral(peripheralUUID) {
|
||||||
|
previousPeerID = $0.peerID
|
||||||
|
$0.peerID = peerID
|
||||||
}
|
}
|
||||||
|
guard updated != nil else { return }
|
||||||
|
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
||||||
|
// so the old peer no longer claims this link.
|
||||||
|
if let previousPeerID, previousPeerID != peerID,
|
||||||
|
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
||||||
|
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
||||||
|
}
|
||||||
|
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||||
assertOwned()
|
assertOwned()
|
||||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||||
if let peerID {
|
// Only clear (or repair) the reverse map when it points at the removed
|
||||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
// link: with duplicate links to one peer, removing a stale duplicate
|
||||||
|
// must not strand the peer's surviving bound link.
|
||||||
|
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
|
||||||
|
// Prefer a writable survivor: repairing onto a link that is
|
||||||
|
// mid-service-rediscovery would strand directed sends until the
|
||||||
|
// characteristic comes back.
|
||||||
|
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
|
||||||
|
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
|
||||||
|
peerToPeripheralUUID[peerID] = survivorUUID
|
||||||
|
} else {
|
||||||
|
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return peerID
|
return peerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,4 @@ final class BLELogRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeAll() {
|
|
||||||
queue.sync {
|
|
||||||
lastLogTimeByKey.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ final class BLENoisePacketHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// Returns true when the handshake message was processed successfully.
|
||||||
|
/// Callers use this to distinguish an authenticated replacement completion
|
||||||
|
/// from a rejected candidate while an older session remains established.
|
||||||
|
@discardableResult
|
||||||
|
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Use NoiseEncryptionService for handshake processing
|
// Use NoiseEncryptionService for handshake processing
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||||
@@ -72,14 +76,26 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// which will send any pending messages at the right time
|
||||||
|
return true
|
||||||
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
|
// The candidate was already discarded by the session manager.
|
||||||
|
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||||
|
// handshake or recreate state for the attacker-selected ID.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
SecureLogger.error("Failed to process handshake: \(error)")
|
||||||
// Try initiating a new handshake
|
// Try initiating a new handshake
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
|||||||
@@ -7,11 +7,54 @@ struct BLEOutboundFragmentTransferRequest {
|
|||||||
let maxChunk: Int?
|
let maxChunk: Int?
|
||||||
let directedPeer: PeerID?
|
let directedPeer: PeerID?
|
||||||
let transferId: String?
|
let transferId: String?
|
||||||
|
let requireDirectPeerLink: Bool
|
||||||
|
let requireNoiseAuthenticatedPeerLink: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
pad: Bool,
|
||||||
|
maxChunk: Int?,
|
||||||
|
directedPeer: PeerID?,
|
||||||
|
transferId: String?,
|
||||||
|
requireDirectPeerLink: Bool = false,
|
||||||
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
|
) {
|
||||||
|
self.packet = packet
|
||||||
|
self.pad = pad
|
||||||
|
self.maxChunk = maxChunk
|
||||||
|
self.directedPeer = directedPeer
|
||||||
|
self.transferId = transferId
|
||||||
|
self.requireDirectPeerLink = requireDirectPeerLink
|
||||||
|
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
|
||||||
|
}
|
||||||
|
|
||||||
var resolvedTransferId: String? {
|
var resolvedTransferId: String? {
|
||||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
return transferId ?? packet.payload.sha256Hex()
|
return transferId ?? packet.payload.sha256Hex()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Content identity independent of the caller-chosen transfer ID: the
|
||||||
|
/// same file resent through another path (gossip-sync replay, retry)
|
||||||
|
/// arrives with a different explicit transferId but identical payload.
|
||||||
|
var contentKey: String? {
|
||||||
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
|
return packet.payload.sha256Hex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transactional admission for strict fragment trains. Durable callers may
|
||||||
|
/// commit only when every fragment was accepted; the first rejection stops
|
||||||
|
/// the train and reports failure so the original remains retryable.
|
||||||
|
enum BLEStrictFragmentAdmission {
|
||||||
|
static func admitAll<Fragment>(
|
||||||
|
_ fragments: [Fragment],
|
||||||
|
accepting: (Fragment) -> Bool
|
||||||
|
) -> Bool {
|
||||||
|
for fragment in fragments where !accepting(fragment) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEOutboundFragmentTransferScheduler {
|
struct BLEOutboundFragmentTransferScheduler {
|
||||||
@@ -23,6 +66,16 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
enum SubmitResult {
|
enum SubmitResult {
|
||||||
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||||
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
||||||
|
/// Strict direct-link requests are transactional: returning false to
|
||||||
|
/// their durable owner must mean no process-local copy remains that
|
||||||
|
/// can transmit later. They are therefore start-or-reject, never
|
||||||
|
/// admitted to `pendingTransfers`.
|
||||||
|
case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?)
|
||||||
|
/// The same file is already being (or waiting to be) fragmented out
|
||||||
|
/// to an audience covering this request; sending it again would just
|
||||||
|
/// double the airtime (field-verified: one 41KB voice file went out
|
||||||
|
/// as two complete fragment streams).
|
||||||
|
case droppedDuplicate(request: BLEOutboundFragmentTransferRequest, activeTransferId: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CancelResult {
|
enum CancelResult {
|
||||||
@@ -38,14 +91,34 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct ActiveTransferState {
|
private struct ActiveTransferState {
|
||||||
let totalFragments: Int
|
var totalFragments: Int
|
||||||
var sentFragments: Int
|
var sentFragments: Int
|
||||||
var workItems: [DispatchWorkItem]
|
var workItems: [DispatchWorkItem]
|
||||||
|
var contentKey: String?
|
||||||
|
var directedPeer: PeerID?
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activeTransfers: [String: ActiveTransferState] = [:]
|
private var activeTransfers: [String: ActiveTransferState] = [:]
|
||||||
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
/// A transfer of the same content whose audience covers `directedPeer`:
|
||||||
|
/// a broadcast covers every peer; a directed transfer covers only its
|
||||||
|
/// recipient. A directed resend to a peer NOT covered by what's in
|
||||||
|
/// flight (different recipient of a private file) is never a duplicate.
|
||||||
|
private func coveringDuplicate(contentKey: String, directedPeer: PeerID?) -> String? {
|
||||||
|
for (transferId, state) in activeTransfers where state.contentKey == contentKey {
|
||||||
|
if state.directedPeer == nil || state.directedPeer == directedPeer {
|
||||||
|
return transferId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for request in pendingTransfers where request.contentKey == contentKey {
|
||||||
|
if request.directedPeer == nil || request.directedPeer == directedPeer {
|
||||||
|
return request.resolvedTransferId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var activeCount: Int {
|
var activeCount: Int {
|
||||||
activeTransfers.count
|
activeTransfers.count
|
||||||
}
|
}
|
||||||
@@ -69,17 +142,39 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
return .start(request: request, reservedTransferId: nil)
|
return .start(request: request, reservedTransferId: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only requests without an explicit transferId are dropped as
|
||||||
|
// duplicates: those are resend paths (gossip-sync replay, directed
|
||||||
|
// spool) with no UI waiting on them. An app-initiated send carries a
|
||||||
|
// transferId whose progress events the UI tracks, so it always runs.
|
||||||
|
if request.transferId == nil,
|
||||||
|
let contentKey = request.contentKey,
|
||||||
|
let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) {
|
||||||
|
return .droppedDuplicate(request: request, activeTransferId: coveringId)
|
||||||
|
}
|
||||||
|
|
||||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
if request.requireDirectPeerLink {
|
||||||
|
return .rejectedStrict(request: request, transferId: transferId)
|
||||||
|
}
|
||||||
pendingTransfers.append(request)
|
pendingTransfers.append(request)
|
||||||
return .queued(request: request, transferId: transferId, position: .back)
|
return .queued(request: request, transferId: transferId, position: .back)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard activeTransfers[transferId] == nil else {
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
if request.requireDirectPeerLink {
|
||||||
|
return .rejectedStrict(request: request, transferId: transferId)
|
||||||
|
}
|
||||||
pendingTransfers.insert(request, at: 0)
|
pendingTransfers.insert(request, at: 0)
|
||||||
return .queued(request: request, transferId: transferId, position: .front)
|
return .queued(request: request, transferId: transferId, position: .front)
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
activeTransfers[transferId] = ActiveTransferState(
|
||||||
|
totalFragments: 0,
|
||||||
|
sentFragments: 0,
|
||||||
|
workItems: [],
|
||||||
|
contentKey: request.contentKey,
|
||||||
|
directedPeer: request.directedPeer
|
||||||
|
)
|
||||||
return .start(request: request, reservedTransferId: transferId)
|
return .start(request: request, reservedTransferId: transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +183,11 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
totalFragments: Int,
|
totalFragments: Int,
|
||||||
workItems: [DispatchWorkItem]
|
workItems: [DispatchWorkItem]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard activeTransfers[transferId] != nil else { return false }
|
guard var state = activeTransfers[transferId] else { return false }
|
||||||
activeTransfers[transferId] = ActiveTransferState(
|
state.totalFragments = totalFragments
|
||||||
totalFragments: totalFragments,
|
state.sentFragments = 0
|
||||||
sentFragments: 0,
|
state.workItems = workItems
|
||||||
workItems: workItems
|
activeTransfers[transferId] = state
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +243,25 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
|
|
||||||
while availableSlots > 0, !pendingTransfers.isEmpty {
|
while availableSlots > 0, !pendingTransfers.isEmpty {
|
||||||
let request = pendingTransfers.removeFirst()
|
let request = pendingTransfers.removeFirst()
|
||||||
availableSlots -= 1
|
|
||||||
|
|
||||||
guard let transferId = request.resolvedTransferId else {
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
availableSlots -= 1
|
||||||
results.append(.start(request: request, reservedTransferId: nil))
|
results.append(.start(request: request, reservedTransferId: nil))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A queued duplicate of content that started while it waited
|
||||||
|
// must not resend the whole file once the slot frees up (same
|
||||||
|
// explicit-transferId exemption as submit).
|
||||||
|
if request.transferId == nil,
|
||||||
|
let contentKey = request.contentKey,
|
||||||
|
let coveringId = coveringDuplicate(contentKey: contentKey, directedPeer: request.directedPeer) {
|
||||||
|
results.append(.droppedDuplicate(request: request, activeTransferId: coveringId))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
availableSlots -= 1
|
||||||
|
|
||||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
pendingTransfers.insert(request, at: 0)
|
pendingTransfers.insert(request, at: 0)
|
||||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
@@ -168,7 +274,13 @@ struct BLEOutboundFragmentTransferScheduler {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
activeTransfers[transferId] = ActiveTransferState(
|
||||||
|
totalFragments: 0,
|
||||||
|
sentFragments: 0,
|
||||||
|
workItems: [],
|
||||||
|
contentKey: request.contentKey,
|
||||||
|
directedPeer: request.directedPeer
|
||||||
|
)
|
||||||
results.append(.start(request: request, reservedTransferId: transferId))
|
results.append(.start(request: request, reservedTransferId: transferId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,22 +20,15 @@ enum BLEOutboundLinkPlanner {
|
|||||||
excludedLinks: Set<BLEIngressLinkID>,
|
excludedLinks: Set<BLEIngressLinkID>,
|
||||||
peripheralPeerBindings: [String: PeerID] = [:],
|
peripheralPeerBindings: [String: PeerID] = [:],
|
||||||
centralPeerBindings: [String: PeerID] = [:],
|
centralPeerBindings: [String: PeerID] = [:],
|
||||||
directedOnlyPeer: PeerID?
|
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
||||||
|
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
|
||||||
|
directedOnlyPeer: PeerID?,
|
||||||
|
requireDirectPeerLink: Bool = false
|
||||||
) -> BLEOutboundLinkPlan {
|
) -> BLEOutboundLinkPlan {
|
||||||
if let minLimit = minimumLinkLimit(
|
|
||||||
peripheralWriteLimits: peripheralWriteLimits,
|
|
||||||
centralNotifyLimits: centralNotifyLimits
|
|
||||||
), packet.type != MessageType.fragment.rawValue,
|
|
||||||
dataCount > minLimit {
|
|
||||||
return BLEOutboundLinkPlan(
|
|
||||||
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
|
|
||||||
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
|
|
||||||
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
|
|
||||||
shouldSpoolDirectedPacket: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
||||||
|
// Direct announces bypass the per-peer duplicate-link collapse so
|
||||||
|
// every live link gets bound (see BLEFanoutSelector.selectLinks).
|
||||||
|
let isDirectAnnounce = packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||||
let selectedLinks = BLEFanoutSelector.selectLinks(
|
let selectedLinks = BLEFanoutSelector.selectLinks(
|
||||||
peripheralIDs: peripheralIDs,
|
peripheralIDs: peripheralIDs,
|
||||||
centralIDs: centralIDs,
|
centralIDs: centralIDs,
|
||||||
@@ -43,11 +36,37 @@ enum BLEOutboundLinkPlanner {
|
|||||||
excludedLinks: excludedLinks,
|
excludedLinks: excludedLinks,
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
centralPeerBindings: centralPeerBindings,
|
centralPeerBindings: centralPeerBindings,
|
||||||
|
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
|
||||||
|
collapseDuplicatePeerLinks: !isDirectAnnounce,
|
||||||
directedPeerHint: directedPeerHint,
|
directedPeerHint: directedPeerHint,
|
||||||
|
requireDirectPeerLink: requireDirectPeerLink,
|
||||||
packetType: packet.type,
|
packetType: packet.type,
|
||||||
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fragment only for links that this packet can actually use. Looking
|
||||||
|
// at every connected link before directed-peer selection lets an
|
||||||
|
// unrelated peer's MTU make an oversized directed send look routable,
|
||||||
|
// even though every resulting fragment will select zero target links.
|
||||||
|
let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in
|
||||||
|
selectedLinks.peripheralIDs.contains(id) ? limit : nil
|
||||||
|
}
|
||||||
|
let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in
|
||||||
|
selectedLinks.centralIDs.contains(id) ? limit : nil
|
||||||
|
}
|
||||||
|
if let minLimit = minimumLinkLimit(
|
||||||
|
peripheralWriteLimits: selectedPeripheralLimits,
|
||||||
|
centralNotifyLimits: selectedCentralLimits
|
||||||
|
), packet.type != MessageType.fragment.rawValue,
|
||||||
|
dataCount > minLimit {
|
||||||
|
return BLEOutboundLinkPlan(
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
|
||||||
|
selectedLinks: selectedLinks,
|
||||||
|
shouldSpoolDirectedPacket: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return BLEOutboundLinkPlan(
|
return BLEOutboundLinkPlan(
|
||||||
directedPeerHint: directedPeerHint,
|
directedPeerHint: directedPeerHint,
|
||||||
fragmentChunkSize: nil,
|
fragmentChunkSize: nil,
|
||||||
|
|||||||
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
|
|||||||
guard !pending.isEmpty else { return }
|
guard !pending.isEmpty else { return }
|
||||||
notifications.insert(contentsOf: pending, at: 0)
|
notifications.insert(contentsOf: pending, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes a disconnected target from target-specific retries. Broadcast
|
||||||
|
/// entries (`targets == nil`) remain valid for the surviving subscriber
|
||||||
|
/// set; an entry with no targets left is discarded entirely.
|
||||||
|
mutating func removeTarget(where matches: (Target) -> Bool) {
|
||||||
|
notifications = notifications.compactMap { notification in
|
||||||
|
guard let targets = notification.targets else { return notification }
|
||||||
|
let remaining = targets.filter { !matches($0) }
|
||||||
|
guard !remaining.isEmpty else { return nil }
|
||||||
|
return BLEPendingNotification(data: notification.data, targets: remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
||||||
|
// push every ~490-byte signed voice packet over the MTU into the
|
||||||
|
// fragment path.
|
||||||
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority {
|
||||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case .fragment:
|
case .fragment:
|
||||||
|
|||||||
@@ -46,8 +46,25 @@ struct BLEOutboundWriteBuffer {
|
|||||||
priority: BLEOutboundWritePriority,
|
priority: BLEOutboundWritePriority,
|
||||||
capBytes: Int
|
capBytes: Int
|
||||||
) -> EnqueueResult {
|
) -> EnqueueResult {
|
||||||
|
enqueueReportingAcceptance(
|
||||||
|
data: data,
|
||||||
|
for: peripheralID,
|
||||||
|
priority: priority,
|
||||||
|
capBytes: capBytes
|
||||||
|
).result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueues while also reporting whether the newly offered write survived
|
||||||
|
/// priority trimming. `EnqueueResult.enqueued` alone cannot express that:
|
||||||
|
/// a full queue may immediately trim the new lowest-priority item.
|
||||||
|
mutating func enqueueReportingAcceptance(
|
||||||
|
data: Data,
|
||||||
|
for peripheralID: String,
|
||||||
|
priority: BLEOutboundWritePriority,
|
||||||
|
capBytes: Int
|
||||||
|
) -> (result: EnqueueResult, accepted: Bool) {
|
||||||
guard data.count <= capBytes else {
|
guard data.count <= capBytes else {
|
||||||
return .oversized(bytes: data.count)
|
return (.oversized(bytes: data.count), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var queue = writesByPeripheralID[peripheralID] ?? []
|
var queue = writesByPeripheralID[peripheralID] ?? []
|
||||||
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
||||||
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
|
let accepted = insertIndex < queue.count
|
||||||
|
return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
||||||
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drops link-specific ciphertext when that physical link is gone. Keeping
|
||||||
|
/// the dictionary entry would let rotating peripheral UUIDs accumulate a
|
||||||
|
/// fresh per-link byte cap indefinitely.
|
||||||
|
mutating func discardAll(for peripheralID: String) {
|
||||||
|
writesByPeripheralID[peripheralID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
||||||
guard !items.isEmpty else { return }
|
guard !items.isEmpty else { return }
|
||||||
var existing = writesByPeripheralID[peripheralID] ?? []
|
var existing = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
var capabilities: PeerCapabilities = []
|
var capabilities: PeerCapabilities = []
|
||||||
|
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||||
|
var bridgeGeohash: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
@@ -117,6 +119,15 @@ struct BLEPeerRegistry {
|
|||||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rendezvous cell advertised by any bridge-capable peer, if one is
|
||||||
|
/// known — lets location-less devices join the island's rendezvous.
|
||||||
|
func advertisedBridgeGeohash() -> String? {
|
||||||
|
peers.values
|
||||||
|
.filter { $0.capabilities.contains(.bridge) }
|
||||||
|
.compactMap(\.bridgeGeohash)
|
||||||
|
.first
|
||||||
|
}
|
||||||
|
|
||||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
@@ -141,20 +152,22 @@ struct BLEPeerRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
|
||||||
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
|
||||||
let hasCollision = peers.values.contains {
|
|
||||||
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
|
||||||
} || selfNickname == info.nickname
|
|
||||||
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func markDisconnected(_ peerID: PeerID) {
|
mutating func markDisconnected(_ peerID: PeerID) {
|
||||||
guard var info = peers[peerID] else { return }
|
guard var info = peers[peerID] else { return }
|
||||||
info.isConnected = false
|
info.isConnected = false
|
||||||
peers[peerID] = info
|
peers[peerID] = info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flips an already-known peer to connected. Returns false when the peer
|
||||||
|
/// is unknown or already connected (nothing changed).
|
||||||
|
@discardableResult
|
||||||
|
mutating func markConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
guard var info = peers[peerID], !info.isConnected else { return false }
|
||||||
|
info.isConnected = true
|
||||||
|
peers[peerID] = info
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
||||||
guard var peer = peers[peerID] else { return }
|
guard var peer = peers[peerID] else { return }
|
||||||
peer.lastSeen = date
|
peer.lastSeen = date
|
||||||
@@ -168,7 +181,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date,
|
now: Date,
|
||||||
capabilities: PeerCapabilities = []
|
capabilities: PeerCapabilities = [],
|
||||||
|
bridgeGeohash: String? = nil
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
@@ -185,7 +199,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now,
|
lastSeen: now,
|
||||||
capabilities: capabilities
|
capabilities: capabilities,
|
||||||
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|||||||
@@ -122,10 +122,18 @@ final class BLEPublicMessageHandler {
|
|||||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
|
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
var resolvedSelfMessageID: String? = nil
|
let messageID: String?
|
||||||
if peerID == env.localPeerID() {
|
if peerID == env.localPeerID() {
|
||||||
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
|
messageID = env.takeSelfBroadcastMessageID(packet)
|
||||||
|
} else {
|
||||||
|
// The wire carries no message ID; derive the stable one every
|
||||||
|
// device agrees on so bridged copies dedup against the radio copy.
|
||||||
|
messageID = MeshMessageIdentity.stableID(
|
||||||
|
senderIDHex: peerID.id,
|
||||||
|
timestampMs: packet.timestamp,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
|
env.deliverPublicMessage(peerID, senderNickname, content, ts, messageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,16 +51,16 @@ struct BLEReceivePipeline {
|
|||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
// remote handover toward a relayed announce rides this same
|
// remote handover toward a relayed announce rides this same
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
// Ping/pong diagnostics ride it too: probes need the same
|
||||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
|
||||||
// Ping/pong diagnostics also ride it: probes need the same
|
|
||||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||||
|
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||||
|
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||||
|| packet.type == MessageType.nostrCarrier.rawValue
|
|
||||||
|| packet.type == MessageType.ping.rawValue
|
|| packet.type == MessageType.ping.rawValue
|
||||||
|| packet.type == MessageType.pong.rawValue) && packet.recipientID != nil,
|
|| packet.type == MessageType.pong.rawValue
|
||||||
|
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
@@ -70,6 +70,7 @@ struct BLEReceivePipeline {
|
|||||||
// announce-class TTL headroom so alerts travel the extra hop.
|
// announce-class TTL headroom so alerts travel the extra hop.
|
||||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||||
&& BoardWire.urgentFlag(in: packet.payload),
|
&& BoardWire.urgentFlag(in: packet.payload),
|
||||||
|
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
|
||||||
degree: degree,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
|
||||||
|
/// links) so the service can arm pending background connections against them
|
||||||
|
/// when the app leaves the foreground. Generic over the peripheral type so
|
||||||
|
/// the eviction/expiry logic is testable without CoreBluetooth.
|
||||||
|
final class BLERecentPeripheralCache<Peripheral> {
|
||||||
|
private struct Entry {
|
||||||
|
let peripheral: Peripheral
|
||||||
|
var lastSeen: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entries: [String: Entry] = [:]
|
||||||
|
private let capacity: Int
|
||||||
|
private let maxAge: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
|
||||||
|
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
|
||||||
|
) {
|
||||||
|
self.capacity = capacity
|
||||||
|
self.maxAge = maxAge
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int { entries.count }
|
||||||
|
|
||||||
|
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
|
||||||
|
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
|
||||||
|
guard entries.count > capacity else { return }
|
||||||
|
// Inserts overshoot capacity by at most one; evict the stalest entry
|
||||||
|
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
|
||||||
|
entries.removeValue(forKey: stalest.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most-recently-seen peripherals eligible for a pending background
|
||||||
|
/// connect, freshest first, capped at `limit`. Expired entries are
|
||||||
|
/// pruned as a side effect.
|
||||||
|
func reconnectTargets(
|
||||||
|
now: Date,
|
||||||
|
limit: Int,
|
||||||
|
excluding: (String) -> Bool
|
||||||
|
) -> [(peripheralID: String, peripheral: Peripheral)] {
|
||||||
|
let cutoff = now.addingTimeInterval(-maxAge)
|
||||||
|
entries = entries.filter { $0.value.lastSeen >= cutoff }
|
||||||
|
guard limit > 0 else { return [] }
|
||||||
|
return entries
|
||||||
|
.filter { !excluding($0.key) }
|
||||||
|
.sorted { $0.value.lastSeen > $1.value.lastSeen }
|
||||||
|
.prefix(limit)
|
||||||
|
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Decides which central-role connections (peripheral links we own) are
|
||||||
|
/// redundant duplicates of a peer's live link.
|
||||||
|
///
|
||||||
|
/// One connection per role per peer is the normal dual-role topology (each
|
||||||
|
/// device is both central and peripheral). After a BLE state-restoration
|
||||||
|
/// relaunch, though, the same phone can reappear under a fresh peripheral
|
||||||
|
/// UUID while the restored connection lives on — leaving several live
|
||||||
|
/// central-role connections to one peer, each carrying every packet
|
||||||
|
/// (field-verified: 2-3x airtime on all traffic). Only same-role duplicates
|
||||||
|
/// are retired; the peer's central-role subscription on our peripheral
|
||||||
|
/// manager is its own connection to manage, and it runs the same policy.
|
||||||
|
enum BLERedundantLinkPolicy {
|
||||||
|
struct PeripheralLink: Equatable {
|
||||||
|
let uuid: String
|
||||||
|
let peerID: PeerID?
|
||||||
|
let isConnected: Bool
|
||||||
|
/// Whether the link has a discovered characteristic (is writable).
|
||||||
|
/// A link mid-service-rediscovery (didModifyServices cleared it)
|
||||||
|
/// must never be kept over a writable duplicate.
|
||||||
|
let hasCharacteristic: Bool
|
||||||
|
|
||||||
|
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
|
||||||
|
self.uuid = uuid
|
||||||
|
self.peerID = peerID
|
||||||
|
self.isConnected = isConnected
|
||||||
|
self.hasCharacteristic = hasCharacteristic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link to keep when a peer has several connected bound peripheral
|
||||||
|
/// links, or nil when there is nothing to consolidate. Prefers the
|
||||||
|
/// ingress link of the verified direct announce that triggered the check
|
||||||
|
/// (the strongest liveness proof available), falling back to the peer's
|
||||||
|
/// most recently bound link — but only among writable links while any
|
||||||
|
/// exist: keeping a characteristic-less link and cancelling the writable
|
||||||
|
/// duplicate would strand outbound traffic on the central link until
|
||||||
|
/// rediscovery finishes. When neither anchor is a viable candidate,
|
||||||
|
/// consolidation waits for a later announce rather than guessing.
|
||||||
|
static func keptPeripheralUUID(
|
||||||
|
ingressPeripheralUUID: String?,
|
||||||
|
mostRecentlyBoundUUID: String?,
|
||||||
|
links: [PeripheralLink],
|
||||||
|
peerID: PeerID
|
||||||
|
) -> String? {
|
||||||
|
let bound = links.filter { $0.peerID == peerID && $0.isConnected }
|
||||||
|
guard bound.count > 1 else { return nil }
|
||||||
|
|
||||||
|
let writable = bound.filter(\.hasCharacteristic)
|
||||||
|
let candidates = writable.isEmpty ? bound : writable
|
||||||
|
|
||||||
|
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
|
||||||
|
return ingressPeripheralUUID
|
||||||
|
}
|
||||||
|
if let mostRecentlyBoundUUID, candidates.contains(where: { $0.uuid == mostRecentlyBoundUUID }) {
|
||||||
|
return mostRecentlyBoundUUID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connected peripheral links bound to the peer other than the kept one.
|
||||||
|
static func peripheralUUIDsToRetire(
|
||||||
|
links: [PeripheralLink],
|
||||||
|
peerID: PeerID,
|
||||||
|
keeping keptUUID: String
|
||||||
|
) -> [String] {
|
||||||
|
links
|
||||||
|
.filter { $0.peerID == peerID && $0.isConnected && $0.uuid != keptUUID }
|
||||||
|
.map(\.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,8 @@ import Foundation
|
|||||||
/// Decides whether an outbound directed packet should carry a v2 source
|
/// Decides whether an outbound directed packet should carry a v2 source
|
||||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
||||||
enum BLESourceRouteOriginationPolicy {
|
enum BLESourceRouteOriginationPolicy {
|
||||||
/// Why a packet kept flood/direct-write behavior instead of routing.
|
/// Returns the intermediate-hop route to attach, or nil to keep the
|
||||||
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
|
/// current flood/direct-write behavior unchanged.
|
||||||
enum FloodReason: String {
|
|
||||||
case relayedNotOriginator = "not originator"
|
|
||||||
case broadcast = "broadcast recipient"
|
|
||||||
case noTTLHeadroom = "link-local ttl"
|
|
||||||
case recipientDirect = "recipient direct"
|
|
||||||
case routeSuppressed = "route suppressed→flood"
|
|
||||||
case noPath = "no v2 path"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The routing decision for an originated packet.
|
|
||||||
enum Decision: Equatable {
|
|
||||||
/// Keep flood/direct-write behavior unchanged; carries the reason.
|
|
||||||
case flood(FloodReason)
|
|
||||||
/// Originate a v2 source route over these intermediate hops.
|
|
||||||
case route([Data])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether to originate a v2 source route (with its hops) or keep
|
|
||||||
/// flood/direct-write, with the reason for the latter.
|
|
||||||
///
|
///
|
||||||
/// Routes are only originated when every gate passes:
|
/// Routes are only originated when every gate passes:
|
||||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||||
@@ -38,22 +19,22 @@ enum BLESourceRouteOriginationPolicy {
|
|||||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||||
/// routed send, and
|
/// routed send, and
|
||||||
/// - the topology yields a complete path.
|
/// - the topology yields a complete path.
|
||||||
static func decide(
|
static func route(
|
||||||
for packet: BitchatPacket,
|
for packet: BitchatPacket,
|
||||||
to recipient: PeerID,
|
to recipient: PeerID,
|
||||||
localPeerIDData: Data,
|
localPeerIDData: Data,
|
||||||
isRecipientConnected: (PeerID) -> Bool,
|
isRecipientConnected: (PeerID) -> Bool,
|
||||||
shouldAttemptRoute: (PeerID) -> Bool,
|
shouldAttemptRoute: (PeerID) -> Bool,
|
||||||
computeRoute: (PeerID) -> [Data]?
|
computeRoute: (PeerID) -> [Data]?
|
||||||
) -> Decision {
|
) -> [Data]? {
|
||||||
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
|
guard packet.senderID == localPeerIDData else { return nil }
|
||||||
guard let recipientData = packet.recipientID,
|
guard let recipientData = packet.recipientID,
|
||||||
recipientData.count == 8,
|
recipientData.count == 8,
|
||||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
|
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
||||||
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
|
guard packet.ttl > 1 else { return nil }
|
||||||
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
|
guard !isRecipientConnected(recipient) else { return nil }
|
||||||
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
|
guard shouldAttemptRoute(recipient) else { return nil }
|
||||||
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
|
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
||||||
return .route(route)
|
return route
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||