Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed105f311a | ||
|
|
8ebb7411a7 | ||
|
|
fb60c929eb | ||
|
|
678795adcf | ||
|
|
37e973bf9f | ||
|
|
8c002b8942 | ||
|
|
6533629362 | ||
|
|
dd4e2f6e86 | ||
|
|
55187ac5c6 |
@@ -1,20 +0,0 @@
|
||||
# Prevent Github Languages stats skewing:
|
||||
|
||||
# Binaries and assets
|
||||
**/*.xcframework/** linguist-vendored
|
||||
**/*.xcassets/** linguist-vendored
|
||||
|
||||
# Generated files
|
||||
**/*.pbxproj linguist-generated
|
||||
**/*.storyboard linguist-generated
|
||||
Package.resolved linguist-generated
|
||||
|
||||
# Downloaded CSVs
|
||||
relays/online_relays_gps.csv linguist-vendored
|
||||
|
||||
# Docs
|
||||
**/*.md linguist-documentation
|
||||
|
||||
# Configs
|
||||
Configs/*.xcconfig linguist-documentation
|
||||
**/*.plist linguist-documentation
|
||||
@@ -1,85 +0,0 @@
|
||||
name: Arti Binary Provenance
|
||||
|
||||
# The Arti xcframework is a vendored binary; these checks turn the policy in
|
||||
# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate:
|
||||
# 1. The checked-in binary must match the hash manifest in the provenance doc.
|
||||
# 2. A PR that changes the binary must also change at least one provenance
|
||||
# input (Rust source, lockfile, build script, or the doc itself).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "localPackages/Arti/**"
|
||||
- "docs/ARTI-BINARY-PROVENANCE.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- "localPackages/Arti/**"
|
||||
- "docs/ARTI-BINARY-PROVENANCE.md"
|
||||
|
||||
jobs:
|
||||
verify-hashes:
|
||||
name: Verify xcframework hashes against provenance doc
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Compare artifact hashes with manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
doc="docs/ARTI-BINARY-PROVENANCE.md"
|
||||
|
||||
# Extract the manifest: lines of "<sha256> <path>" from the doc.
|
||||
grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \
|
||||
| sort -k2 > expected.txt
|
||||
|
||||
if [ ! -s expected.txt ]; then
|
||||
echo "::error::No hash manifest found in $doc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hash the same file set the doc documents.
|
||||
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \
|
||||
| sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt
|
||||
|
||||
if ! diff -u expected.txt actual.txt; then
|
||||
echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR."
|
||||
exit 1
|
||||
fi
|
||||
echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest."
|
||||
|
||||
require-provenance-evidence:
|
||||
name: Binary changes must ship with provenance inputs
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check changed files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="origin/${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
|
||||
changed=$(git diff --name-only "$base"...HEAD)
|
||||
echo "Changed files:"
|
||||
echo "$changed"
|
||||
|
||||
if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then
|
||||
echo "No binary artifact changes; nothing to verify."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then
|
||||
echo "Binary change is accompanied by provenance inputs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")."
|
||||
exit 1
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Fetch GeoRelays Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -1,194 +0,0 @@
|
||||
name: Build & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Swift Tests (${{ matrix.name }})
|
||||
runs-on: macos-latest
|
||||
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||
# default (observed: intermittent app-suite hangs starving the queue).
|
||||
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||
timeout-minutes: 25
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
matrix:
|
||||
include:
|
||||
- name: app
|
||||
path: .
|
||||
- name: BitLogger
|
||||
path: localPackages/BitLogger
|
||||
- name: BitFoundation
|
||||
path: localPackages/BitFoundation
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
|
||||
# the runner image. A standalone swift.org toolchain (setup-swift) broke
|
||||
# whenever the image's Xcode moved ahead of it ("this SDK is not
|
||||
# supported by the compiler").
|
||||
- name: Note toolchain version (cache key)
|
||||
id: swift-version
|
||||
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
|
||||
|
||||
- name: Build tests
|
||||
# Built separately so the hang watchdog below times only test
|
||||
# execution: a cold-cache coverage build on a slow runner can
|
||||
# legitimately take several minutes, and is already bounded by the
|
||||
# 15-minute job timeout.
|
||||
run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}
|
||||
|
||||
- name: Run Tests
|
||||
# Perf benchmarks are excluded here and run in their own serial step
|
||||
# below: measuring while parallel test processes contend for cores
|
||||
# produces noisy numbers, and the XCTest measure machinery has hung
|
||||
# intermittently under parallel workers on loaded runners. Excluded
|
||||
# via --skip (not just the env guard): every app run since the
|
||||
# baselines landed timed out at the 15-minute job limit with the
|
||||
# baseline tests dispatched into the parallel phase.
|
||||
#
|
||||
# The watchdog samples any still-running test processes after 5
|
||||
# minutes (the suite passes in seconds when healthy; the build is
|
||||
# done by this step) and kills the run, so a hang fails fast with
|
||||
# stacks in the log instead of a silent timeout.
|
||||
env:
|
||||
BITCHAT_SKIP_PERF_BASELINES: "1"
|
||||
run: |
|
||||
swift test --skip-build --parallel --quiet --enable-code-coverage \
|
||||
--skip PerformanceBaselineTests \
|
||||
--package-path ${{ matrix.path }} &
|
||||
test_pid=$!
|
||||
(
|
||||
sleep 300
|
||||
if kill -0 "$test_pid" 2>/dev/null; then
|
||||
echo "::group::Tests still running after 5 minutes — sampling before kill"
|
||||
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
|
||||
echo "--- sample of pid $pid ---"
|
||||
sample "$pid" 5 2>/dev/null || true
|
||||
done
|
||||
echo "::endgroup::"
|
||||
pkill -KILL -P "$test_pid" 2>/dev/null || true
|
||||
kill -KILL "$test_pid" 2>/dev/null || true
|
||||
fi
|
||||
) &
|
||||
watchdog_pid=$!
|
||||
wait "$test_pid" && status=0 || status=$?
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
|
||||
# Benchmarks run serially on an otherwise idle runner for stable
|
||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||
- name: Run performance benchmarks (serial)
|
||||
if: matrix.name == 'app'
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
|
||||
run: swift test --quiet --filter PerformanceBaselineTests
|
||||
|
||||
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||
# catches algorithmic regressions, never runner variance. If a metric
|
||||
# still lands below floor (a saturated runner can dip one), the script
|
||||
# re-runs the benchmarks — appending to the same log and keeping each
|
||||
# benchmark's best value per metric — so noise clears on retry while a
|
||||
# real regression fails every attempt. Floors are never lowered by this.
|
||||
- name: Performance floor gate
|
||||
if: matrix.name == 'app'
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
# job log so coverage trends are visible on every PR. No thresholds —
|
||||
# this must never be the reason a build goes red.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||
else
|
||||
echo "No coverage data found; skipping summary."
|
||||
fi
|
||||
|
||||
# SPM tests do not link the shipping app targets. This job covers the
|
||||
# iOS-conditional paths and both universal Release link configurations.
|
||||
ios-build:
|
||||
name: Build Release apps (universal)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# Build both simulator architectures so CI validates every vendored
|
||||
# Arti simulator slice and the configuration that ships.
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-configuration Release \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
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 \
|
||||
build
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
lint:
|
||||
name: SwiftLint (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
# privilege we can: a read-only token, and no credentials left in the
|
||||
# checkout for the container to find.
|
||||
permissions:
|
||||
contents: read
|
||||
container:
|
||||
# Tag for readability, digest for immutability (tags can be repointed).
|
||||
# Bump both together, deliberately — never a floating tag.
|
||||
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
@@ -8,7 +8,9 @@ plans/
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
@@ -55,8 +57,7 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
*.xcodeproj/xcshareddata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
@@ -67,9 +68,6 @@ __pycache__/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
## Cache
|
||||
.cache/
|
||||
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
@@ -77,6 +75,3 @@ TestResult.xcresult/
|
||||
*.xcresult/
|
||||
build.log
|
||||
*.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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"]}}
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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
|
||||
@@ -1,34 +0,0 @@
|
||||
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .claude
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
- build
|
||||
- localPackages/*/.build
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- type_name
|
||||
- identifier_name
|
||||
- statement_position
|
||||
- implicit_optional_initialization
|
||||
- force_try
|
||||
- vertical_whitespace
|
||||
- for_where
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- type_body_length
|
||||
- file_length
|
||||
- large_tuple
|
||||
- force_cast
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
@@ -37,7 +37,7 @@ This three-message pattern provides:
|
||||
#### NoiseEncryptionService
|
||||
The main service managing all Noise operations:
|
||||
```swift
|
||||
final class NoiseEncryptionService {
|
||||
class NoiseEncryptionService {
|
||||
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let sessionManager: NoiseSessionManager
|
||||
private let channelEncryption = NoiseChannelEncryption()
|
||||
@@ -47,7 +47,7 @@ final class NoiseEncryptionService {
|
||||
#### NoiseSession
|
||||
Individual session state for each peer:
|
||||
```swift
|
||||
final class NoiseSession {
|
||||
class NoiseSession {
|
||||
private var handshakeState: NoiseHandshakeState?
|
||||
private var sendCipher: NoiseCipherState?
|
||||
private var receiveCipher: NoiseCipherState?
|
||||
@@ -58,7 +58,7 @@ final class NoiseSession {
|
||||
#### NoiseSessionManager
|
||||
Thread-safe session management:
|
||||
```swift
|
||||
final class NoiseSessionManager {
|
||||
class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#include "Release.xcconfig"
|
||||
|
||||
// Optional include of local configs
|
||||
#include? "Local.xcconfig"
|
||||
@@ -1,5 +0,0 @@
|
||||
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
|
||||
DEVELOPMENT_TEAM = ABC123
|
||||
|
||||
// Unique bundle id to be able to register and run locally
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
@@ -1,12 +0,0 @@
|
||||
MARKETING_VERSION = 1.7.1
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0
|
||||
SWIFT_VERSION = 5.0
|
||||
|
||||
DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||
APP_GROUP_ID = group.chat.bitchat
|
||||
@@ -14,16 +14,16 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@cp project.yml project.yml.backup 2>/dev/null || true
|
||||
@# Backup other files that get modified by xcodegen
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
@@ -44,8 +44,13 @@ patch-for-macos: backup
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Generate Xcode project with patches
|
||||
generate: patch-for-macos
|
||||
@echo "Generating Xcode project..."
|
||||
@xcodegen generate
|
||||
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
build: check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
@@ -70,7 +75,9 @@ clean: restore
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
||||
@xcodegen generate
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
@@ -99,9 +106,11 @@ nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f project.yml.backup 2>/dev/null || true
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
@@ -1,155 +1,139 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: July 2026*
|
||||
*Last updated: January 2025*
|
||||
|
||||
## Our Commitment
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Summary
|
||||
|
||||
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
|
||||
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
|
||||
- **No sale of data** — the project does not sell user data or build advertising profiles.
|
||||
- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code.
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **No servers** - Everything happens on your device and through peer-to-peer connections
|
||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||
- **Open source** - You can verify these claims by reading our code
|
||||
|
||||
## What bitchat Stores on Your Device
|
||||
## What Information bitchat Stores
|
||||
|
||||
1. **Identity and cryptographic keys**
|
||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
||||
### On Your Device Only
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
||||
1. **Identity Key**
|
||||
- A cryptographic key generated on first launch
|
||||
- Stored locally in your device's secure storage
|
||||
- Allows you to maintain "favorite" relationships across app restarts
|
||||
- Never leaves your device
|
||||
|
||||
3. **Private group state**
|
||||
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||
- 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.
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
- Stored only on your device
|
||||
- Shared with peers you communicate with
|
||||
|
||||
4. **Queued and carried private messages**
|
||||
- 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.
|
||||
- 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.
|
||||
- A panic wipe deletes both stores.
|
||||
3. **Message History** (if enabled)
|
||||
- When room owners enable retention, messages are saved locally
|
||||
- Stored encrypted on your device
|
||||
- You can delete this at any time
|
||||
|
||||
5. **Recent public mesh messages and notices**
|
||||
- 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.
|
||||
- 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.
|
||||
- These items are public to the mesh or board where they are posted; they are not confidential messages.
|
||||
4. **Favorite Peers**
|
||||
- Public keys of peers you mark as favorites
|
||||
- Stored only on your device
|
||||
- Allows you to recognize these peers in future sessions
|
||||
|
||||
6. **Media attachments**
|
||||
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||
- 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.
|
||||
### 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:
|
||||
- Active peer connections (forgotten when app closes)
|
||||
- Routing information for message delivery
|
||||
- Cached messages for offline peers (12 hours max)
|
||||
|
||||
## Temporary Session Data
|
||||
## 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:
|
||||
- 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 Nearby Mesh Users
|
||||
### With Room Members
|
||||
|
||||
Depending on the feature you use, nearby peers can receive:
|
||||
When you join a password-protected room:
|
||||
- Your messages are visible to others with the password
|
||||
- Your nickname appears in the member list
|
||||
- Room owners can see you've joined
|
||||
|
||||
- Your chosen nickname and public Noise/signing identity material.
|
||||
- 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.
|
||||
- Public mesh messages, public notices, and group-control packets you intentionally send.
|
||||
- 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.
|
||||
## What We DON'T Do
|
||||
|
||||
Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state.
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Tracks your location
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
|
||||
### With Private Group Members
|
||||
## Encryption
|
||||
|
||||
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.
|
||||
All private messages use end-to-end encryption:
|
||||
- **X25519** for key exchange
|
||||
- **AES-256-GCM** for message encryption
|
||||
- **Ed25519** for digital signatures
|
||||
- **Argon2id** for password-protected rooms
|
||||
|
||||
### With Nostr Relays and Internet Gateways
|
||||
## Your Rights
|
||||
|
||||
Internet-backed features are optional. When enabled or used:
|
||||
You have complete control:
|
||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
||||
- **Leave Anytime**: Close the app and your presence disappears
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
|
||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||
- 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.
|
||||
- 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.
|
||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||
- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices.
|
||||
## Bluetooth & Permissions
|
||||
|
||||
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.
|
||||
|
||||
## Location and Apple Services
|
||||
|
||||
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.
|
||||
|
||||
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
|
||||
- A selected geohash can still reveal an approximate area to peers and relays.
|
||||
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
|
||||
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app.
|
||||
|
||||
## Microphone, Camera, and Media Permissions
|
||||
|
||||
- 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.
|
||||
- Voice-note and live-audio files can remain in Application Support under the media retention rules above.
|
||||
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
|
||||
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
|
||||
|
||||
## 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 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.
|
||||
bitchat requires Bluetooth permission to function:
|
||||
- Used only for peer-to-peer communication
|
||||
- No location data is accessed or stored
|
||||
- Bluetooth is not used for tracking
|
||||
- You can revoke this permission at any time in system settings
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
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.
|
||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||
|
||||
## 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
|
||||
- **Everything Else**: Exists only during active sessions
|
||||
|
||||
## Security Measures
|
||||
|
||||
- All communication is encrypted
|
||||
- No data transmitted to servers (there are none)
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
|
||||
## Changes to 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.
|
||||
If we update this policy:
|
||||
- The "Last updated" date will change
|
||||
- The updated policy will be included in the app
|
||||
- No retroactive changes can affect data (since we don't collect any)
|
||||
|
||||
## Contact
|
||||
|
||||
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
|
||||
|
||||
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
|
||||
- Open an issue on GitHub.
|
||||
## Philosophy
|
||||
|
||||
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 servers, no surveillance. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
*This policy is released into the public domain under The Unlicense, like the project itself.*
|
||||
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
|
||||
|
||||
@@ -4,7 +4,6 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "bitchat",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
@@ -13,58 +12,25 @@ let package = Package(
|
||||
.executable(
|
||||
name: "bitchat",
|
||||
targets: ["bitchat"]
|
||||
)
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
dependencies:[
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "bitchat",
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Tor", package: "Arti")
|
||||
.product(name: "P256K", package: "swift-secp256k1")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"Assets.xcassets",
|
||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
"ViewModels/Extensions/README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
"LaunchScreen.storyboard"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation")
|
||||
],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md",
|
||||
// CI perf gate data (read by scripts/check-perf-floors.sh),
|
||||
// not a test resource.
|
||||
"Performance/perf-floors.json"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization"),
|
||||
// Only the vector fixture: declaring the whole "Noise"
|
||||
// directory would claim its .swift test files as resources
|
||||
// and silently drop them from compilation.
|
||||
.process("Noise/NoiseTestVectors.json")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
@@ -19,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
@@ -91,32 +94,45 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Using Xcode
|
||||
### Option 1: Using XcodeGen (Recommended)
|
||||
|
||||
1. Install XcodeGen if you haven't already:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
2. Generate the Xcode project:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
3. Open the generated project:
|
||||
```bash
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
|
||||
To run on a device there're a few steps to prepare the code:
|
||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||
- Entitlements need to be updated manually (TODO: Automate):
|
||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||
### Option 2: Using Swift Package Manager
|
||||
|
||||
### Option 2: Using `just`
|
||||
1. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
brew install just
|
||||
cd bitchat
|
||||
open Package.swift
|
||||
```
|
||||
|
||||
2. Select your target device and run
|
||||
|
||||
### Option 3: Manual Xcode Project
|
||||
|
||||
1. Open Xcode and create a new iOS/macOS App
|
||||
2. Copy all Swift files from the `bitchat` directory into your project
|
||||
3. Update Info.plist with Bluetooth permissions
|
||||
4. Set deployment target to iOS 16.0 / macOS 13.0
|
||||
|
||||
### Option 4: just
|
||||
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -1,141 +1,268 @@
|
||||
# bitchat Protocol Whitepaper
|
||||
# BitChat Protocol Whitepaper
|
||||
|
||||
**Version 2.0**
|
||||
**Version 1.1**
|
||||
|
||||
**Date: July 6, 2026**
|
||||
**Date: July 25, 2025**
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
|
||||
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Goals
|
||||
## 1. Introduction
|
||||
|
||||
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
||||
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
|
||||
|
||||
## 2. Architecture Overview
|
||||
The design goals of the BitChat Protocol are:
|
||||
|
||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||
* **Confidentiality:** All communication must be unreadable to third parties.
|
||||
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
||||
* **Integrity:** Messages cannot be tampered with in transit.
|
||||
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
||||
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
||||
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
||||
|
||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
This paper specifies the technical details of the protocol designed to meet these goals.
|
||||
|
||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||
---
|
||||
|
||||
## 3. Identity
|
||||
## 2. Protocol Stack
|
||||
|
||||
Each device holds two long-term key pairs in the Keychain:
|
||||
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
||||
|
||||
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||
* an **Ed25519 signing key** for packet signatures.
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Application Layer] --> B[Session Layer];
|
||||
B --> C[Encryption Layer];
|
||||
C --> D[Transport Layer];
|
||||
|
||||
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||
subgraph "BitChat Application"
|
||||
A
|
||||
end
|
||||
|
||||
## 4. BLE Mesh Layer
|
||||
subgraph "Message Framing & State"
|
||||
B
|
||||
end
|
||||
|
||||
### 4.1 Packet Format
|
||||
subgraph "Noise Protocol Framework"
|
||||
C
|
||||
end
|
||||
|
||||
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||
subgraph "BLE, Wi-Fi Direct, etc."
|
||||
D
|
||||
end
|
||||
|
||||
### 4.2 Flood Control
|
||||
style A fill:#cde4ff
|
||||
style B fill:#b5d8ff
|
||||
style C fill:#9ac2ff
|
||||
style D fill:#7eadff
|
||||
```
|
||||
|
||||
Relaying is a deterministic controlled flood tuned by local connection degree:
|
||||
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
||||
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
||||
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
||||
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
||||
|
||||
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
|
||||
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
|
||||
* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||
---
|
||||
|
||||
### 4.3 Routing
|
||||
## 3. Identity and Key Management
|
||||
|
||||
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
||||
|
||||
### 4.4 Fragmentation
|
||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
||||
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
||||
|
||||
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
|
||||
### 3.1. Fingerprint
|
||||
|
||||
### 4.5 Presence
|
||||
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
|
||||
|
||||
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
||||
|
||||
## 5. Encryption
|
||||
### 3.2. Identity Management
|
||||
|
||||
### 5.1 Live Sessions: Noise XX
|
||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
||||
|
||||
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||
---
|
||||
|
||||
### 5.2 Offline Seals: Noise X
|
||||
## 4. The Social Trust Layer
|
||||
|
||||
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
||||
|
||||
### 5.3 Nostr Path
|
||||
### 4.1. Peer Verification
|
||||
|
||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
||||
|
||||
## 6. Store and Forward
|
||||
### 4.2. Favorites and Blocking
|
||||
|
||||
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
||||
To improve the user experience and provide control over interactions, the protocol supports:
|
||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
||||
|
||||
### 6.1 Sender Outbox
|
||||
---
|
||||
|
||||
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
|
||||
## 5. The Noise Protocol Layer
|
||||
|
||||
### 6.2 Couriers
|
||||
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
||||
|
||||
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
|
||||
### 5.1. Protocol Name
|
||||
|
||||
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
|
||||
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
|
||||
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
|
||||
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
|
||||
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
|
||||
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
|
||||
The specific Noise protocol implemented is:
|
||||
|
||||
### 6.3 Public History (Gossip Sync)
|
||||
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
||||
|
||||
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
|
||||
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
||||
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
||||
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
||||
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
||||
|
||||
### 6.4 Nostr Mailboxes
|
||||
### 5.2. The `XX` Handshake
|
||||
|
||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
||||
|
||||
### 6.5 Delivery Metrics
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant I as Initiator
|
||||
participant R as Responder
|
||||
|
||||
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
|
||||
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
||||
|
||||
## 7. Application Layer
|
||||
I->>R: -> e
|
||||
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
||||
|
||||
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
||||
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
||||
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
||||
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
||||
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
||||
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
|
||||
R->>I: <- e, ee, s, es
|
||||
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))
|
||||
|
||||
I->>R: -> s, se
|
||||
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))
|
||||
|
||||
Note over I, R: Handshake complete. Transport keys derived.
|
||||
```
|
||||
|
||||
**Handshake Flow:**
|
||||
|
||||
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
|
||||
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
|
||||
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
|
||||
|
||||
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
|
||||
|
||||
### 5.3. Session Management
|
||||
|
||||
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
|
||||
* Creating sessions for new peers.
|
||||
* Coordinating the handshake process to prevent race conditions.
|
||||
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
|
||||
* Periodically checking if sessions need to be re-keyed for enhanced security.
|
||||
|
||||
---
|
||||
|
||||
## 6. The BitChat Session and Application Protocol
|
||||
|
||||
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
|
||||
|
||||
### 6.1. Binary Packet Format (`BitchatPacket`)
|
||||
|
||||
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
|
||||
| **Header** | **13** | **Fixed-size header** |
|
||||
| Version | 1 | Protocol version (currently `1`). |
|
||||
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
|
||||
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
|
||||
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
|
||||
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
|
||||
| Payload Length | 2 | `UInt16` length of the payload field. |
|
||||
| **Variable** | **...** | **Variable-size fields** |
|
||||
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
|
||||
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
|
||||
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
|
||||
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|---------------------|--------------|--------------------------------------------------------------------------|
|
||||
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
|
||||
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
|
||||
| ID | 1 + len | `UUID` string for the message. |
|
||||
| Sender | 1 + len | Nickname of the sender. |
|
||||
| Content | 2 + len | The UTF-8 encoded message content. |
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
|
||||
|
||||
### 7.1. Direct Connection
|
||||
|
||||
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
|
||||
|
||||
### 7.2. Efficient Gossip with Bloom Filters
|
||||
|
||||
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
|
||||
|
||||
The logic is as follows:
|
||||
|
||||
1. A peer receives a packet.
|
||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
||||
3. If the packet is new, its ID is added to the Bloom filter.
|
||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
||||
|
||||
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
|
||||
|
||||
### 7.3. Time-To-Live (TTL)
|
||||
|
||||
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
|
||||
|
||||
### 7.4. Private vs. Broadcast Messages
|
||||
|
||||
The routing logic respects the confidentiality of private messages:
|
||||
|
||||
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
|
||||
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
|
||||
|
||||
### 7.5. Message Reliability and Lifecycle
|
||||
|
||||
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
|
||||
|
||||
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
|
||||
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
|
||||
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
|
||||
|
||||
### 7.6. Fragmentation
|
||||
|
||||
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
|
||||
|
||||
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
|
||||
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
|
||||
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
|
||||
|
||||
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||
|
||||
## 9. Future Work
|
||||
|
||||
* Prekey-based forward secrecy for courier envelopes.
|
||||
* Couriered media beyond the 16 KiB text cap.
|
||||
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||
* Multi-hop courier routing informed by encounter history.
|
||||
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
|
||||
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
||||
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
||||
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
|
||||
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
|
||||
|
||||
---
|
||||
|
||||
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
||||
## 9. Conclusion
|
||||
|
||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||
BuildableName = "bitchatShareExtension.appex"
|
||||
BlueprintName = "bitchatShareExtension"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||
BuildableName = "bitchatTests_iOS.xctest"
|
||||
BlueprintName = "bitchatTests_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,105 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||
BuildableName = "bitchatTests_macOS.xctest"
|
||||
BlueprintName = "bitchatTests_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,80 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
case background
|
||||
}
|
||||
|
||||
enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||
case willStart
|
||||
case willRestart
|
||||
case didBecomeReady
|
||||
case preferenceChanged
|
||||
}
|
||||
|
||||
enum AppEvent: Sendable, Equatable {
|
||||
case launched
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
case nostrRelayConnectionChanged(Bool)
|
||||
case terminationRequested
|
||||
}
|
||||
|
||||
actor AppEventStream {
|
||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||
|
||||
func emit(_ event: AppEvent) {
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(event)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Identity key for a direct conversation. Equality and hashing use the
|
||||
/// canonical `id` only; `routingPeerID` carries the transport-level peer ID
|
||||
/// the conversation is keyed under (see `ConversationID.directPeer`).
|
||||
struct PeerHandle: Sendable, Identifiable {
|
||||
let id: String
|
||||
let routingPeerID: PeerID
|
||||
}
|
||||
|
||||
extension PeerHandle: Equatable {
|
||||
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerHandle: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum ConversationID: Hashable, Sendable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case direct(PeerHandle)
|
||||
|
||||
init(channelID: ChannelID) {
|
||||
switch channelID {
|
||||
case .mesh:
|
||||
self = .mesh
|
||||
case .location(let channel):
|
||||
self = .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppChromeModel: ObservableObject {
|
||||
@Published private(set) var hasUnreadPrivateMessages = false
|
||||
@Published var nickname: String
|
||||
@Published var showingFingerprintFor: PeerID?
|
||||
@Published var isAppInfoPresented = 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 bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
}
|
||||
|
||||
var shouldSuppressScreenshotNotification: Bool {
|
||||
isLocationChannelsSheetPresented || isAppInfoPresented
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
self.nickname = nickname
|
||||
if chatViewModel.nickname != nickname {
|
||||
chatViewModel.nickname = nickname
|
||||
}
|
||||
}
|
||||
|
||||
func validateAndSaveNickname() {
|
||||
chatViewModel.validateAndSaveNickname()
|
||||
if nickname != chatViewModel.nickname {
|
||||
nickname = chatViewModel.nickname
|
||||
}
|
||||
}
|
||||
|
||||
func openMostRelevantPrivateChat() {
|
||||
chatViewModel.openMostRelevantPrivateChat()
|
||||
}
|
||||
|
||||
func showFingerprint(for peerID: PeerID) {
|
||||
showingFingerprintFor = peerID
|
||||
}
|
||||
|
||||
func clearFingerprint() {
|
||||
showingFingerprintFor = nil
|
||||
}
|
||||
|
||||
func presentAppInfo() {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
func presentNotices(geoTab: Bool = false) {
|
||||
noticesSheetPrefersGeoTab = geoTab
|
||||
isNoticesSheetPresented = true
|
||||
}
|
||||
|
||||
/// Builds the mesh topology map model from the transport's gossiped
|
||||
/// 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.
|
||||
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||
let mesh = chatViewModel.meshService
|
||||
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||
let nicknames = mesh.getPeerNicknames()
|
||||
|
||||
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||
let isSelf = peerID == snapshot.localPeerID
|
||||
let label: String
|
||||
if isSelf {
|
||||
label = chatViewModel.nickname
|
||||
} else {
|
||||
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
||||
}
|
||||
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
||||
}
|
||||
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
||||
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
||||
}
|
||||
|
||||
func triggerScreenshotPrivacyWarning() {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
private func bind(privateInboxModel: PrivateInboxModel) {
|
||||
privateInboxModel.$unreadPeerIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] unreadPeerIDs in
|
||||
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] nickname in
|
||||
guard let self, self.nickname != nickname else { return }
|
||||
self.nickname = nickname
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$showBluetoothAlert
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$showBluetoothAlert)
|
||||
|
||||
chatViewModel.$bluetoothAlertMessage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothAlertMessage)
|
||||
|
||||
chatViewModel.$bluetoothState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothState)
|
||||
|
||||
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
import UserNotifications
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppRuntime: ObservableObject {
|
||||
let chatViewModel: ChatViewModel
|
||||
let events = AppEventStream()
|
||||
/// Single source of truth for conversation message state and selection
|
||||
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||
let conversations: ConversationStore
|
||||
let publicChatModel: PublicChatModel
|
||||
let privateInboxModel: PrivateInboxModel
|
||||
let privateConversationModel: PrivateConversationModel
|
||||
let verificationModel: VerificationModel
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private var lastNostrRelayConnectedState = false
|
||||
private var didHandleInitialNostrConnection = false
|
||||
|
||||
#if os(iOS)
|
||||
private var didHandleInitialActive = false
|
||||
private var didEnterBackground = false
|
||||
#endif
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let conversations = ConversationStore()
|
||||
let peerIdentityStore = PeerIdentityStore()
|
||||
let locationPresenceStore = LocationPresenceStore()
|
||||
let locationManager = LocationChannelManager.shared
|
||||
self.conversations = conversations
|
||||
self.chatViewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain),
|
||||
conversations: conversations,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore,
|
||||
locationManager: locationManager
|
||||
)
|
||||
self.publicChatModel = PublicChatModel(conversations: conversations)
|
||||
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
self.privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
self.verificationModel = VerificationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
self.conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
conversations: conversations
|
||||
)
|
||||
self.peerListModel = PeerListModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
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()
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else {
|
||||
checkForSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
started = true
|
||||
NotificationDelegate.shared.runtime = self
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService)
|
||||
announceInitialTorStatusIfNeeded()
|
||||
|
||||
Task(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let nickname = await MainActor.run { self.chatViewModel.nickname }
|
||||
let npub = await MainActor.run {
|
||||
try? self.idBridge.getCurrentNostrIdentity()?.npub
|
||||
}
|
||||
await MainActor.run {
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
}
|
||||
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
|
||||
record(.launched)
|
||||
record(.startupCompleted)
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
record(.openedURL(url.absoluteString))
|
||||
|
||||
if url.scheme == "bitchat", url.host == "share" {
|
||||
checkForSharedContent()
|
||||
}
|
||||
}
|
||||
|
||||
func handleDidBecomeActiveNotification() {
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func handleMacDidBecomeActiveNotification() {
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
record(.scenePhaseChanged(.background))
|
||||
TorManager.shared.setAppForeground(false)
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
chatViewModel.endGeohashSampling()
|
||||
NostrRelayManager.shared.disconnect()
|
||||
didEnterBackground = true
|
||||
|
||||
case .active:
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
|
||||
|
||||
if didHandleInitialActive && didEnterBackground {
|
||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||
TorManager.shared.ensureRunningOnForeground()
|
||||
}
|
||||
} else {
|
||||
didHandleInitialActive = true
|
||||
}
|
||||
|
||||
didEnterBackground = false
|
||||
|
||||
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
|
||||
Task.detached {
|
||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||
await MainActor.run {
|
||||
TorURLSession.shared.rebuild()
|
||||
NostrRelayManager.shared.resetAllConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
|
||||
case .inactive:
|
||||
record(.scenePhaseChanged(.inactive))
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func applicationWillTerminate() {
|
||||
record(.terminationRequested)
|
||||
chatViewModel.applicationWillTerminate()
|
||||
}
|
||||
|
||||
func handleNotificationResponse(
|
||||
identifier: String,
|
||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
if actionIdentifier == NotificationService.waveActionID {
|
||||
chatViewModel.sendMeshWave()
|
||||
return
|
||||
}
|
||||
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
record(.notificationOpened(peerID: peerID))
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
|
||||
record(.deepLinkOpened(deepLink))
|
||||
openExternalURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
func presentationOptions(
|
||||
forNotificationIdentifier identifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
if conversations.selectedPrivatePeerID == peerID {
|
||||
return []
|
||||
}
|
||||
return [.banner, .sound]
|
||||
}
|
||||
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deepLink = userInfo["deeplink"] as? String,
|
||||
let geohash = deepLink.components(separatedBy: "/").last,
|
||||
case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||
channel.geohash == geohash {
|
||||
return []
|
||||
}
|
||||
|
||||
return [.banner, .sound]
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppRuntime {
|
||||
func bindRuntimeObservers() {
|
||||
NostrRelayManager.shared.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isConnected in
|
||||
self?.handleNostrRelayConnectionChanged(isConnected)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.willRestart))
|
||||
self?.chatViewModel.handleTorWillRestart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||
self?.chatViewModel.handleTorDidBecomeReady()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.willStart))
|
||||
self?.chatViewModel.handleTorWillStart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleScreenshotCaptured()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
#endif
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
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 {
|
||||
// A partial or malformed handoff must not linger in the shared
|
||||
// app-group container indefinitely.
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
clearSharedContent()
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
case .text:
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
|
||||
record(.sharedContentAccepted(contentKind))
|
||||
}
|
||||
|
||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||
record(.nostrRelayConnectionChanged(isConnected))
|
||||
|
||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||
lastNostrRelayConnectedState = isConnected
|
||||
|
||||
guard started, becameConnected else { return }
|
||||
|
||||
let isInitialConnection = !didHandleInitialNostrConnection
|
||||
didHandleInitialNostrConnection = true
|
||||
|
||||
if !chatViewModel.nostrHandlersSetup {
|
||||
chatViewModel.setupNostrMessageHandling()
|
||||
chatViewModel.nostrHandlersSetup = true
|
||||
}
|
||||
|
||||
guard !isInitialConnection else { return }
|
||||
|
||||
chatViewModel.resubscribeCurrentGeohash()
|
||||
chatViewModel.geoChannelCoordinator?.refreshSampling()
|
||||
}
|
||||
|
||||
func announceInitialTorStatusIfNeeded() {
|
||||
if TorManager.shared.torEnforced &&
|
||||
!chatViewModel.torStatusAnnounced &&
|
||||
TorManager.shared.isAutoStartAllowed() {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
if appChromeModel.isLocationChannelsSheetPresented {
|
||||
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||
return
|
||||
}
|
||||
|
||||
if appChromeModel.isAppInfoPresented {
|
||||
return
|
||||
}
|
||||
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
}
|
||||
|
||||
func openExternalURL(_ url: URL) {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
|
||||
func record(_ event: AppEvent) {
|
||||
Task {
|
||||
await events.emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,925 +0,0 @@
|
||||
//
|
||||
// ConversationStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// Single source of truth for conversation message state (see
|
||||
// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per
|
||||
// `ConversationID`; all mutations flow through the store's intent API and
|
||||
// every mutation emits a `ConversationChange` after state is consistent.
|
||||
//
|
||||
// The store also owns conversation selection: the active public channel and
|
||||
// the selected private peer (the two UI selection axes) plus the derived
|
||||
// `selectedConversationID`.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - Conversation
|
||||
|
||||
/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`).
|
||||
///
|
||||
/// Publishing granularity is per conversation: views observe ONE
|
||||
/// `Conversation` object, so an append to chat A never invalidates observers
|
||||
/// of chat B.
|
||||
///
|
||||
/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent
|
||||
/// API may mutate a conversation, keeping the store the sole writer.
|
||||
@MainActor
|
||||
final class Conversation: ObservableObject, Identifiable {
|
||||
let id: ConversationID
|
||||
/// Maximum retained messages; oldest are trimmed on overflow.
|
||||
let cap: Int
|
||||
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||
/// delivery-status lookup. Kept in sync on every mutation:
|
||||
/// - tail append: single insert
|
||||
/// - out-of-order insert: suffix reindex from the insertion point
|
||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||
/// rebuild does not change the asymptotics, and trim only happens once
|
||||
/// the cap (1337) is reached. Simple and correct beats the
|
||||
/// offset-tracking alternative here.
|
||||
private var indexByMessageID: [String: Int] = [:]
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
self.cap = max(1, cap)
|
||||
}
|
||||
|
||||
// MARK: Reads
|
||||
|
||||
func containsMessage(withID messageID: String) -> Bool {
|
||||
indexByMessageID[messageID] != nil
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
/// All message IDs currently in this conversation (unordered).
|
||||
var messageIDs: Dictionary<String, Int>.Keys {
|
||||
indexByMessageID.keys
|
||||
}
|
||||
|
||||
// MARK: Store-internal mutations
|
||||
|
||||
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
|
||||
/// evicted by the cap so the store can keep its message-ID →
|
||||
/// conversation map exact.
|
||||
fileprivate struct InsertResult {
|
||||
let inserted: Bool
|
||||
let trimmedMessageIDs: [String]
|
||||
|
||||
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
|
||||
}
|
||||
|
||||
fileprivate enum UpsertOutcome {
|
||||
case appended(trimmedMessageIDs: [String])
|
||||
case updated
|
||||
}
|
||||
|
||||
/// Inserts a message in timestamp order, deduplicating by message ID.
|
||||
/// Fast path appends when the timestamp is >= the current tail;
|
||||
/// otherwise a binary search finds the upper-bound insertion point so
|
||||
/// arrival order is preserved among equal timestamps.
|
||||
/// Reports `inserted: false` if a message with the same ID already exists.
|
||||
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
|
||||
guard indexByMessageID[message.id] == nil else { return .duplicate }
|
||||
|
||||
if let last = messages.last, message.timestamp < last.timestamp {
|
||||
let index = insertionIndex(for: message.timestamp)
|
||||
messages.insert(message, at: index)
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
}
|
||||
|
||||
/// Replace-or-append by message ID. An existing message keeps its
|
||||
/// timeline position (in-place updates like media progress reuse the
|
||||
/// original timestamp); a new message goes through ordered insertion.
|
||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
let result = insert(message)
|
||||
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
|
||||
}
|
||||
|
||||
/// Applies a delivery status keyed by message ID, honoring the
|
||||
/// no-downgrade rule (the SOLE enforcement point — every delivery
|
||||
/// update flows through the store): equal statuses are skipped, and
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
message.deliveryStatus = status
|
||||
// BitchatMessage is a reference type; write back through the
|
||||
// subscript so the @Published wrapper emits.
|
||||
messages[index] = message
|
||||
return true
|
||||
}
|
||||
|
||||
/// Republishes a message without changing state. Used for mirrored
|
||||
/// copies that share a BitchatMessage instance: the first conversation's
|
||||
/// status apply mutated the shared object, so this conversation's
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
fileprivate func setUnread(_ unread: Bool) -> Bool {
|
||||
guard isUnread != unread else { return false }
|
||||
isUnread = unread
|
||||
return true
|
||||
}
|
||||
|
||||
/// Removes a single message by ID. Returns the removed message, or
|
||||
/// `nil` when no message with that ID exists.
|
||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
reindex(from: index)
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Removes every message matching `predicate`. Returns the removed
|
||||
/// message IDs (empty when nothing matched).
|
||||
fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] {
|
||||
var removedIDs: [String] = []
|
||||
messages.removeAll { message in
|
||||
guard predicate(message) else { return false }
|
||||
removedIDs.append(message.id)
|
||||
return true
|
||||
}
|
||||
guard !removedIDs.isEmpty else { return [] }
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
|
||||
/// Appends human-readable invariant violations for this conversation
|
||||
/// (empty when healthy): the ID index must be the exact inverse of the
|
||||
/// messages array, the cap must hold, and timestamps must be
|
||||
/// non-decreasing (equal timestamps keep arrival order, so only strict
|
||||
/// inversions are violations). O(messages); allocates only on violation.
|
||||
fileprivate func collectInvariantViolations(into violations: inout [String], label: String) {
|
||||
if indexByMessageID.count != messages.count {
|
||||
violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages")
|
||||
}
|
||||
if messages.count > cap {
|
||||
violations.append("\(label): \(messages.count) messages exceeds cap \(cap)")
|
||||
}
|
||||
var previousTimestamp: Date?
|
||||
for position in messages.indices {
|
||||
let message = messages[position]
|
||||
// Count equality + every message resolving to its own position
|
||||
// proves the index is exactly the inverse map (no stale extras).
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if index != position {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||
}
|
||||
} else {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||
}
|
||||
if let previousTimestamp, message.timestamp < previousTimestamp {
|
||||
violations.append("\(label): timestamp order violated at \(position)")
|
||||
}
|
||||
previousTimestamp = message.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
|
||||
guard let current else { return false }
|
||||
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) {
|
||||
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
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper-bound binary search: first index whose timestamp is strictly
|
||||
/// greater than `timestamp`, so equal-timestamp messages keep arrival
|
||||
/// order.
|
||||
private func insertionIndex(for timestamp: Date) -> Int {
|
||||
var low = 0
|
||||
var high = messages.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if messages[mid].timestamp <= timestamp {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = index
|
||||
}
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
let overflow = messages.count - cap
|
||||
let trimmedIDs = messages.prefix(overflow).map(\.id)
|
||||
for id in trimmedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConversationChange
|
||||
|
||||
/// Typed mutation events for non-UI consumers (delivery tracking,
|
||||
/// notifications, sync) that need "something changed in conversation X"
|
||||
/// without subscribing to whole message arrays. Emitted on the store's
|
||||
/// `changes` subject AFTER the corresponding state is consistent.
|
||||
enum ConversationChange {
|
||||
case appended(ConversationID, BitchatMessage)
|
||||
case updated(ConversationID, messageID: String)
|
||||
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
|
||||
case messageRemoved(ConversationID, messageID: String)
|
||||
case cleared(ConversationID)
|
||||
case removed(ConversationID)
|
||||
case migrated(from: ConversationID, to: ConversationID)
|
||||
case unreadChanged(ConversationID, isUnread: Bool)
|
||||
}
|
||||
|
||||
// MARK: - ConversationStore
|
||||
|
||||
/// Sole writer and sole holder of conversation message state. All mutations
|
||||
/// go through the intent API below; backing collections are `private(set)`.
|
||||
/// Reads are synchronous — writers and readers share the main actor, so
|
||||
/// after an intent returns every observer sees the result.
|
||||
@MainActor
|
||||
final class ConversationStore: ObservableObject {
|
||||
/// Conversation creation order; published so list-style consumers can
|
||||
/// observe conversations appearing/disappearing without rebuilding from
|
||||
/// the dictionary.
|
||||
@Published private(set) var conversationIDs: [ConversationID] = []
|
||||
@Published private(set) var selectedConversationID: ConversationID?
|
||||
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||
|
||||
// MARK: Selection state
|
||||
// The two UI selection axes: which public channel is active, and which
|
||||
// private chat (if any) is open on top of it. `selectedConversationID`
|
||||
// is derived: the open private chat wins, otherwise the active public
|
||||
// channel's conversation. Mutate via `setActiveChannel` /
|
||||
// `setSelectedPrivatePeer` only.
|
||||
|
||||
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||
|
||||
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
||||
|
||||
/// Store-level message-ID → conversation-membership map for ID-only
|
||||
/// lookups (delivery receipts arrive with a message ID, not a
|
||||
/// conversation). Maintained incrementally at every mutation point —
|
||||
/// all mutation is centralized in the intent API below, so the map is
|
||||
/// exact, never scanned or rebuilt.
|
||||
///
|
||||
/// The value is a `Set` because a private message can legitimately live
|
||||
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
|
||||
/// message into both the stable-key and ephemeral-peer chats
|
||||
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
|
||||
/// copies.
|
||||
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
|
||||
|
||||
/// Monotonic count of messages inserted into any conversation (appends,
|
||||
/// upsert-appends, migration inserts). Field-observability only: the
|
||||
/// periodic store audit folds the delta into its heartbeat line so logs
|
||||
/// carry throughput context. Never read on a hot path.
|
||||
private(set) var appendCount: Int = 0
|
||||
|
||||
/// Sample counter for the mirrored-republish debug log in the ID-only
|
||||
/// `setDeliveryStatus` fan-out (first + every Nth occurrence).
|
||||
private var mirroredRepublishLogCount = 0
|
||||
|
||||
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||
|
||||
// MARK: Intent API
|
||||
|
||||
/// Returns the conversation for `id`, creating it (with the cap policy
|
||||
/// for its kind) on first access.
|
||||
@discardableResult
|
||||
func conversation(for id: ConversationID) -> Conversation {
|
||||
if let existing = conversationsByID[id] {
|
||||
return existing
|
||||
}
|
||||
let conversation = Conversation(id: id, cap: Self.cap(for: id))
|
||||
conversationsByID[id] = conversation
|
||||
conversationIDs.append(id)
|
||||
return conversation
|
||||
}
|
||||
|
||||
/// Appends a message in timestamp order. Returns `false` (and emits
|
||||
/// nothing) if a message with the same ID is already present.
|
||||
@discardableResult
|
||||
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||
let conversation = conversation(for: id)
|
||||
let result = conversation.insert(message)
|
||||
guard result.inserted else { return false }
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
return true
|
||||
}
|
||||
|
||||
/// Replace-or-append by message ID (media progress, edits).
|
||||
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||
let conversation = conversation(for: id)
|
||||
switch conversation.upsert(message) {
|
||||
case .appended(let trimmedMessageIDs):
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
case .updated:
|
||||
changes.send(.updated(id, messageID: message.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a delivery status keyed by message ID. Returns `false` when
|
||||
/// the message is unknown or the update would downgrade the status
|
||||
/// (read beats delivered beats sent).
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool {
|
||||
guard let conversation = conversationsByID[id],
|
||||
conversation.applyDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||
return true
|
||||
}
|
||||
|
||||
/// Applies a delivery status to EVERY conversation containing
|
||||
/// `messageID` (ID-only — delivery receipts don't know conversations;
|
||||
/// mirrored private copies live in two direct chats). Returns `false`
|
||||
/// when the message is unknown or no copy changed (equal status or
|
||||
/// downgrade — read beats delivered beats sent).
|
||||
///
|
||||
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
|
||||
/// instance are mutated by the first conversation's apply. The skipped
|
||||
/// conversations still hold the changed message, so they get an explicit
|
||||
/// republish and `.statusChanged` event - otherwise a view observing the
|
||||
/// mirrored conversation would render stale status. Distinct copies whose
|
||||
/// update was genuinely rejected (downgrade) are left untouched, guarded
|
||||
/// by status equality.
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||
var applied = false
|
||||
var skipped: [ConversationID] = []
|
||||
for id in ids {
|
||||
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
|
||||
applied = true
|
||||
} else {
|
||||
skipped.append(id)
|
||||
}
|
||||
}
|
||||
guard applied else { return false }
|
||||
for id in skipped {
|
||||
guard let conversation = conversationsByID[id],
|
||||
conversation.message(withID: messageID)?.deliveryStatus == status,
|
||||
conversation.republishMessage(withID: messageID) else { continue }
|
||||
// Field proof the mirrored-copy republish path actually fires;
|
||||
// sampled (first + every Nth) so mirrored chats can't spam logs.
|
||||
mirroredRepublishLogCount += 1
|
||||
if mirroredRepublishLogCount == 1
|
||||
|| mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) {
|
||||
SecureLogger.debug(
|
||||
"mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Current delivery status of `messageID` in whichever conversation
|
||||
/// holds it (mirrored copies share status — see `setDeliveryStatus`).
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
|
||||
for id in ids {
|
||||
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Every conversation currently containing `messageID` (empty when the
|
||||
/// message is unknown).
|
||||
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
|
||||
conversationIDsByMessageID[messageID] ?? []
|
||||
}
|
||||
|
||||
func markRead(_ id: ConversationID) {
|
||||
guard unreadConversations.contains(id) else { return }
|
||||
unreadConversations.remove(id)
|
||||
conversationsByID[id]?.setUnread(false)
|
||||
changes.send(.unreadChanged(id, isUnread: false))
|
||||
}
|
||||
|
||||
func markUnread(_ id: ConversationID) {
|
||||
guard !unreadConversations.contains(id) else { return }
|
||||
let conversation = conversation(for: id)
|
||||
unreadConversations.insert(id)
|
||||
conversation.setUnread(true)
|
||||
changes.send(.unreadChanged(id, isUnread: true))
|
||||
}
|
||||
|
||||
/// Selects a conversation (creating it if needed) or clears the
|
||||
/// selection with `nil`.
|
||||
func select(_ id: ConversationID?) {
|
||||
if let id {
|
||||
conversation(for: id)
|
||||
}
|
||||
guard selectedConversationID != id else { return }
|
||||
selectedConversationID = id
|
||||
}
|
||||
|
||||
/// Switches the active public channel. While no private chat is open
|
||||
/// the selection follows the channel.
|
||||
func setActiveChannel(_ channelID: ChannelID) {
|
||||
if activeChannel != channelID {
|
||||
activeChannel = channelID
|
||||
}
|
||||
refreshDerivedSelection()
|
||||
}
|
||||
|
||||
/// Opens a private chat (`nil` closes it, returning the selection to the
|
||||
/// active public channel's conversation).
|
||||
func setSelectedPrivatePeer(_ peerID: PeerID?) {
|
||||
if selectedPrivatePeerID != peerID {
|
||||
selectedPrivatePeerID = peerID
|
||||
}
|
||||
refreshDerivedSelection()
|
||||
}
|
||||
|
||||
private func refreshDerivedSelection() {
|
||||
if let peerID = selectedPrivatePeerID {
|
||||
select(.directPeer(peerID))
|
||||
} else {
|
||||
select(ConversationID(channelID: activeChannel))
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves all messages from `source` into `destination` (the
|
||||
/// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves
|
||||
/// timestamp order, carries unread state over, and hands off the
|
||||
/// selection — mirroring `ChatPrivateConversationCoordinator`'s
|
||||
/// migration semantics. The source conversation is removed. Emits a
|
||||
/// single `.migrated(from:to:)` once the whole move is consistent.
|
||||
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
|
||||
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
|
||||
|
||||
let destinationConversation = conversation(for: destination)
|
||||
for message in sourceConversation.messages {
|
||||
let result = destinationConversation.insert(message)
|
||||
guard result.inserted else { continue }
|
||||
registerMessageID(message.id, in: destination)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
|
||||
}
|
||||
for messageID in sourceConversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: source)
|
||||
}
|
||||
|
||||
let wasUnread = unreadConversations.contains(source)
|
||||
let wasSelected = selectedConversationID == source
|
||||
|
||||
conversationsByID.removeValue(forKey: source)
|
||||
conversationIDs.removeAll { $0 == source }
|
||||
unreadConversations.remove(source)
|
||||
|
||||
if wasUnread, !unreadConversations.contains(destination) {
|
||||
unreadConversations.insert(destination)
|
||||
destinationConversation.setUnread(true)
|
||||
}
|
||||
if wasSelected {
|
||||
selectedConversationID = destination
|
||||
// Keep the private-peer selection axis consistent with the
|
||||
// handed-off selection.
|
||||
if let peerID = selectedPrivatePeerID,
|
||||
source == .directPeer(peerID),
|
||||
case .direct(let destinationHandle) = destination {
|
||||
selectedPrivatePeerID = destinationHandle.routingPeerID
|
||||
}
|
||||
}
|
||||
|
||||
changes.send(.migrated(from: source, to: destination))
|
||||
}
|
||||
|
||||
/// Removes a single message by ID from a conversation. Returns the
|
||||
/// removed message, or `nil` (emitting nothing) when the conversation or
|
||||
/// message is unknown.
|
||||
@discardableResult
|
||||
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
|
||||
guard let conversation = conversationsByID[id],
|
||||
let removed = conversation.remove(messageID: messageID) else {
|
||||
return nil
|
||||
}
|
||||
unregisterMessageID(messageID, from: id)
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Removes every message matching `predicate` from a conversation,
|
||||
/// emitting one `.messageRemoved` per removed message after the
|
||||
/// conversation is consistent. No-op for unknown conversations.
|
||||
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
let removedIDs = conversation.removeAll(where: predicate)
|
||||
unregisterMessageIDs(removedIDs, from: id)
|
||||
for messageID in removedIDs {
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties a conversation's timeline but keeps the conversation (and
|
||||
/// its unread/selection state) alive.
|
||||
func clear(_ id: ConversationID) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversation.clearMessages()
|
||||
changes.send(.cleared(id))
|
||||
}
|
||||
|
||||
/// Removes a conversation entirely, including unread state; clears the
|
||||
/// selection if it pointed at the removed conversation.
|
||||
func removeConversation(_ id: ConversationID) {
|
||||
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversationIDs.removeAll { $0 == id }
|
||||
unreadConversations.remove(id)
|
||||
if selectedConversationID == id {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
changes.send(.removed(id))
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
let removedIDs = conversationIDs
|
||||
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
|
||||
|
||||
conversationsByID.removeAll()
|
||||
conversationIDs.removeAll()
|
||||
unreadConversations.removeAll()
|
||||
conversationIDsByMessageID.removeAll()
|
||||
if selectedConversationID != nil {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
|
||||
for id in removedIDs {
|
||||
changes.send(.removed(id))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
|
||||
/// Total messages across all conversations. O(#conversations) — heartbeat
|
||||
/// logging only, never a hot path.
|
||||
var totalMessageCount: Int {
|
||||
conversationsByID.values.reduce(0) { $0 + $1.messages.count }
|
||||
}
|
||||
|
||||
/// Number of distinct message IDs in the store-level membership map.
|
||||
var messageIDMapCount: Int {
|
||||
conversationIDsByMessageID.count
|
||||
}
|
||||
|
||||
/// Verifies the store's correctness invariants and returns human-readable
|
||||
/// violations (empty = healthy). Intended for a periodic field audit:
|
||||
/// O(total messages) and allocation-free while healthy. Checks:
|
||||
/// - the `conversationIDs` ordering array matches `conversationsByID`
|
||||
/// - per conversation: ID index exact, cap held, timestamp order
|
||||
/// (see `Conversation.collectInvariantViolations`)
|
||||
/// - the message-ID → conversation map matches reality exactly: every
|
||||
/// mapped membership points at a live conversation actually holding
|
||||
/// the message, and total memberships equal total messages (with the
|
||||
/// forward check, equality proves no conversation message is missing
|
||||
/// from the map)
|
||||
/// - `unreadConversations` only references existing conversations
|
||||
/// - `selectedConversationID`, when set, references an existing
|
||||
/// conversation (`select(_:)` creates on selection and
|
||||
/// `removeConversation`/`clearAll` clear it, so existence is the
|
||||
/// invariant for both the channel-derived and direct-peer cases)
|
||||
func auditInvariants() -> [String] {
|
||||
var violations: [String] = []
|
||||
|
||||
if conversationIDs.count != conversationsByID.count {
|
||||
violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)")
|
||||
}
|
||||
for id in conversationIDs where conversationsByID[id] == nil {
|
||||
violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists")
|
||||
}
|
||||
|
||||
var totalMessages = 0
|
||||
for (id, conversation) in conversationsByID {
|
||||
totalMessages += conversation.messages.count
|
||||
conversation.collectInvariantViolations(into: &violations, label: id.auditDescription)
|
||||
}
|
||||
|
||||
var totalMappedMemberships = 0
|
||||
for (messageID, ids) in conversationIDsByMessageID {
|
||||
totalMappedMemberships += ids.count
|
||||
if ids.isEmpty {
|
||||
violations.append("message map: \(messageID.prefix(8))… has an empty membership set")
|
||||
}
|
||||
for id in ids {
|
||||
guard let conversation = conversationsByID[id] else {
|
||||
violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)")
|
||||
continue
|
||||
}
|
||||
if !conversation.containsMessage(withID: messageID) {
|
||||
violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalMappedMemberships != totalMessages {
|
||||
violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages")
|
||||
}
|
||||
|
||||
for id in unreadConversations where conversationsByID[id] == nil {
|
||||
violations.append("unreadConversations contains unknown conversation \(id.auditDescription)")
|
||||
}
|
||||
|
||||
if let selected = selectedConversationID, conversationsByID[selected] == nil {
|
||||
violations.append("selectedConversationID \(selected.auditDescription) has no conversation")
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
private func registerMessageID(_ messageID: String, in id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||
// Single choke point for every successful insertion (append, upsert
|
||||
// append, migration insert) — the audit heartbeat's throughput delta.
|
||||
appendCount += 1
|
||||
}
|
||||
|
||||
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||
guard var ids = conversationIDsByMessageID[messageID] else { return }
|
||||
ids.remove(id)
|
||||
if ids.isEmpty {
|
||||
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||
} else {
|
||||
conversationIDsByMessageID[messageID] = ids
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
|
||||
for messageID in messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cap(for id: ConversationID) -> Int {
|
||||
switch id {
|
||||
case .mesh:
|
||||
return TransportConfig.meshTimelineCap
|
||||
case .geohash:
|
||||
return TransportConfig.geoTimelineCap
|
||||
case .direct:
|
||||
return TransportConfig.privateChatCap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Direct-conversation keying + derived views
|
||||
|
||||
extension ConversationID {
|
||||
/// Direct-conversation ID keyed by the *raw* routing peer ID.
|
||||
///
|
||||
/// Direct conversations are deliberately keyed per `PeerID`, not per
|
||||
/// resolved identity: the private-chat coordinators mirror messages into
|
||||
/// both the ephemeral and stable peer's conversations
|
||||
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
|
||||
/// explicitly, so a raw lookup by whichever peer ID is selected always
|
||||
/// finds the right timeline without an identity-resolution layer.
|
||||
static func directPeer(_ peerID: PeerID) -> ConversationID {
|
||||
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationStore {
|
||||
/// All direct conversations' messages keyed by routing peer ID — the
|
||||
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
|
||||
/// Values are the conversations' backing arrays (COW), so building this
|
||||
/// is O(#conversations), not O(#messages).
|
||||
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
|
||||
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||
messagesByPeerID.reserveCapacity(conversationsByID.count)
|
||||
for (id, conversation) in conversationsByID {
|
||||
guard case .direct(let handle) = id else { continue }
|
||||
messagesByPeerID[handle.routingPeerID] = conversation.messages
|
||||
}
|
||||
return messagesByPeerID
|
||||
}
|
||||
|
||||
/// Unread direct conversations as routing peer IDs — the shape
|
||||
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
|
||||
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
|
||||
var peerIDs = Set<PeerID>()
|
||||
for id in unreadConversations {
|
||||
guard case .direct(let handle) = id else { continue }
|
||||
peerIDs.insert(handle.routingPeerID)
|
||||
}
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
/// `true` when any direct conversation contains a message with `messageID`
|
||||
/// (O(1) via the store-level message-ID → conversation map).
|
||||
func directConversationsContainMessage(withID messageID: String) -> Bool {
|
||||
conversationIDs(forMessageID: messageID).contains { id in
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Message IDs across all direct conversations (read-receipt pruning
|
||||
/// keeps only receipts whose messages still exist).
|
||||
func directMessageIDs() -> Set<String> {
|
||||
var messageIDs = Set<String>()
|
||||
for (id, conversation) in conversationsByID {
|
||||
guard case .direct = id else { continue }
|
||||
messageIDs.formUnion(conversation.messageIDs)
|
||||
}
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Diagnostics support
|
||||
|
||||
extension ConversationID {
|
||||
/// Short, log-safe description for audit/diagnostic lines. Direct
|
||||
/// conversations truncate the handle so full peer keys never hit logs.
|
||||
fileprivate var auditDescription: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "mesh"
|
||||
case .geohash(let geohash):
|
||||
return "geo:\(geohash)"
|
||||
case .direct(let handle):
|
||||
return "direct:\(handle.id.prefix(13))…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Test-only corruption hooks for `auditInvariants()` tests. The store is the
|
||||
// sole writer by design — `Conversation`'s mutators are fileprivate and the
|
||||
// store's backing collections are private — so the inconsistent states the
|
||||
// audit exists to catch CANNOT be manufactured through the intent API. These
|
||||
// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those
|
||||
// impossible states. Never call them outside tests.
|
||||
extension Conversation {
|
||||
/// Points an existing message's index entry at the wrong position
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
func _testRemoveIndexEntry(forMessageID messageID: String) {
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
/// Swaps the first and last messages while keeping the index consistent,
|
||||
/// so ONLY the timestamp-order invariant is violated (requires the two
|
||||
/// messages to have distinct timestamps).
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationStore {
|
||||
/// Adds a map membership that the conversation does not actually hold.
|
||||
func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||
}
|
||||
|
||||
/// Drops a real map membership (conversation message missing from map).
|
||||
func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID]?.remove(id)
|
||||
if conversationIDsByMessageID[messageID]?.isEmpty == true {
|
||||
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends past the conversation cap, bypassing trim (map kept exact so
|
||||
/// only the cap invariant is violated).
|
||||
func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) {
|
||||
let conversation = conversation(for: id)
|
||||
conversation._testAppendBypassingTrim(message)
|
||||
conversationIDsByMessageID[message.id, default: []].insert(id)
|
||||
}
|
||||
|
||||
/// Marks a nonexistent conversation unread without creating it.
|
||||
func _testInsertUnreadConversationID(_ id: ConversationID) {
|
||||
unreadConversations.insert(id)
|
||||
}
|
||||
|
||||
/// Sets the selection directly, without `select(_:)`'s create-on-select.
|
||||
func _testSetSelectedConversationID(_ id: ConversationID?) {
|
||||
selectedConversationID = id
|
||||
}
|
||||
}
|
||||
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Public timeline derived views
|
||||
|
||||
extension ConversationStore {
|
||||
/// Removes a message by ID from whichever public (mesh/geohash)
|
||||
/// conversation contains it. Returns the removed message, if any.
|
||||
@discardableResult
|
||||
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||
for id in conversationIDs(forMessageID: messageID) {
|
||||
switch id {
|
||||
case .mesh, .geohash:
|
||||
return removeMessage(withID: messageID, from: id)
|
||||
case .direct:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var showAutocomplete = false
|
||||
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@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 privateConversationModel: PrivateConversationModel
|
||||
private let conversations: ConversationStore
|
||||
private var activeChannel: ChannelID
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
conversations: ConversationStore
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.privateConversationModel = privateConversationModel
|
||||
self.conversations = conversations
|
||||
self.activeChannel = conversations.activeChannel
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
||||
chatViewModel.currentColorScheme = colorScheme
|
||||
}
|
||||
|
||||
func setCurrentTheme(_ theme: AppTheme) {
|
||||
chatViewModel.currentTheme = theme
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String) {
|
||||
chatViewModel.sendMessage(message)
|
||||
}
|
||||
|
||||
/// Resends a failed private message through the normal send path,
|
||||
/// removing the failed original so the re-submission replaces it
|
||||
/// instead of stacking a duplicate under the red bubble.
|
||||
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
||||
chatViewModel.removePrivateMessage(withID: message.id)
|
||||
chatViewModel.sendMessage(message.content)
|
||||
}
|
||||
|
||||
func clearCurrentConversation() {
|
||||
chatViewModel.sendMessage("/clear")
|
||||
}
|
||||
|
||||
func sendHug(to sender: String) {
|
||||
chatViewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
|
||||
func sendSlap(to sender: String) {
|
||||
chatViewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
|
||||
func block(peerID: PeerID?, displayName: String?) {
|
||||
guard let displayName else { return }
|
||||
|
||||
if let peerID, peerID.isGeoChat,
|
||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
||||
// Mesh: block the peer's stable Noise identity resolved from the
|
||||
// tapped peerID rather than re-resolving a display-name string.
|
||||
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
} else {
|
||||
chatViewModel.sendMessage("/block \(displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
||||
/// the tapped peer's stable identity so the exact row is unblocked — this
|
||||
/// also works for offline peers, which the `/unblock <displayName>` command
|
||||
/// cannot resolve.
|
||||
func unblock(peerID: PeerID, displayName: String) {
|
||||
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||
chatViewModel.completeNickname(nickname, in: &text)
|
||||
}
|
||||
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
|
||||
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||
message.mediaAttachment(for: currentNickname)
|
||||
}
|
||||
|
||||
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
||||
}
|
||||
|
||||
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||
}
|
||||
|
||||
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
return chatViewModel.geohashDisplayName(for: peerID)
|
||||
}
|
||||
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
||||
return nickname
|
||||
}
|
||||
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func processSelectedImage(_ image: UIImage?) {
|
||||
chatViewModel.processThenSendImage(image)
|
||||
}
|
||||
#endif
|
||||
|
||||
func processSelectedImage(from url: URL?) {
|
||||
#if os(macOS)
|
||||
chatViewModel.processThenSendImage(from: url)
|
||||
#endif
|
||||
}
|
||||
|
||||
func sendVoiceNote(at url: 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) {
|
||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
chatViewModel.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$currentNickname)
|
||||
|
||||
chatViewModel.$showAutocomplete
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$showAutocomplete)
|
||||
|
||||
chatViewModel.$autocompleteSuggestions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$autocompleteSuggestions)
|
||||
|
||||
chatViewModel.$isBatchingPublic
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
chatViewModel.$activePublicVoiceTalker
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$activeLiveVoiceTalker)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
self?.activeChannel = channel
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
privateConversationModel.$selectedPeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
// Media transfer is not wired for groups in v1; keep it off so the
|
||||
// composer can't strand a media placeholder that never sends.
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||
return
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
canSendMediaInCurrentContext = true
|
||||
case .location:
|
||||
canSendMediaInCurrentContext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationChannelsModel: ObservableObject {
|
||||
@Published private(set) var permissionState: LocationChannelManager.PermissionState
|
||||
@Published private(set) var availableChannels: [GeohashChannel]
|
||||
@Published private(set) var selectedChannel: ChannelID
|
||||
@Published private(set) var teleported: Bool
|
||||
@Published private(set) var bookmarks: [String]
|
||||
@Published private(set) var bookmarkNames: [String: String]
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||
@Published private(set) var userTorEnabled: Bool
|
||||
@Published private(set) var gatewayEnabled: Bool
|
||||
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private let gateway: GatewayService
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
network: NetworkActivationService? = nil,
|
||||
gateway: GatewayService? = nil
|
||||
) {
|
||||
let manager = manager ?? .shared
|
||||
let network = network ?? .shared
|
||||
let gateway = gateway ?? .shared
|
||||
|
||||
self.manager = manager
|
||||
self.network = network
|
||||
self.gateway = gateway
|
||||
self.gatewayEnabled = gateway.isEnabled
|
||||
self.permissionState = manager.permissionState
|
||||
self.availableChannels = manager.availableChannels
|
||||
self.selectedChannel = manager.selectedChannel
|
||||
self.teleported = manager.teleported
|
||||
self.bookmarks = manager.bookmarks
|
||||
self.bookmarkNames = manager.bookmarkNames
|
||||
self.locationNames = manager.locationNames
|
||||
self.userTorEnabled = network.userTorEnabled
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
var currentBuildingGeohash: String? {
|
||||
availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
}
|
||||
|
||||
func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
guard case .location(let selected) = selectedChannel else { return false }
|
||||
return selected == channel
|
||||
}
|
||||
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
manager.isBookmarked(geohash)
|
||||
}
|
||||
|
||||
func enableLocationChannels() {
|
||||
manager.enableLocationChannels()
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
|
||||
func enableAndRefresh() {
|
||||
manager.enableLocationChannels()
|
||||
manager.refreshChannels()
|
||||
}
|
||||
|
||||
func beginLiveRefresh() {
|
||||
manager.beginLiveRefresh()
|
||||
}
|
||||
|
||||
func endLiveRefresh() {
|
||||
manager.endLiveRefresh()
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
manager.select(channel)
|
||||
}
|
||||
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
manager.markTeleported(for: geohash, flag)
|
||||
}
|
||||
|
||||
func toggleBookmark(_ geohash: String) {
|
||||
manager.toggleBookmark(geohash)
|
||||
}
|
||||
|
||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||
manager.resolveBookmarkNameIfNeeded(for: geohash)
|
||||
}
|
||||
|
||||
func locationName(for level: GeohashChannelLevel) -> String? {
|
||||
locationNames[level]
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
availableChannels.isEmpty else {
|
||||
return
|
||||
}
|
||||
refreshChannels()
|
||||
}
|
||||
|
||||
func openLocationChannel(for geohash: String) {
|
||||
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(normalized.count),
|
||||
normalized.allSatisfy({ allowed.contains($0) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||
let isRegional = availableChannels.contains { $0.geohash == normalized }
|
||||
if !isRegional && !availableChannels.isEmpty {
|
||||
markTeleported(for: normalized, true)
|
||||
}
|
||||
select(.location(channel))
|
||||
}
|
||||
|
||||
func teleport(to geohash: String) {
|
||||
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||
markTeleported(for: normalized, true)
|
||||
select(.location(channel))
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
manager.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$permissionState)
|
||||
|
||||
manager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$availableChannels)
|
||||
|
||||
manager.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedChannel)
|
||||
|
||||
manager.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$teleported)
|
||||
|
||||
manager.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bookmarks)
|
||||
|
||||
manager.$bookmarkNames
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bookmarkNames)
|
||||
|
||||
manager.$locationNames
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$locationNames)
|
||||
|
||||
network.$userTorEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$userTorEnabled)
|
||||
|
||||
gateway.$isEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$gatewayEnabled)
|
||||
}
|
||||
|
||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||
switch length {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
case 8...12: return .building
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationPresenceStore: ObservableObject {
|
||||
@Published private(set) var currentGeohash: String?
|
||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||
@Published private(set) var teleportedGeo: Set<String> = []
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
currentGeohash = geohash?.lowercased()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
geoNicknames = Dictionary(
|
||||
uniqueKeysWithValues: nicknames.map { key, value in
|
||||
(key.lowercased(), value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
geoNicknames.removeAll()
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PeerIdentityStore: ObservableObject {
|
||||
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
|
||||
@Published private(set) var verifiedFingerprints: Set<String> = []
|
||||
|
||||
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
|
||||
private(set) var selectedPrivateChatFingerprint: String?
|
||||
|
||||
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
|
||||
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||||
|
||||
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
|
||||
stablePeerIDsByShortID[peerID]
|
||||
}
|
||||
|
||||
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
|
||||
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
|
||||
}
|
||||
|
||||
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
|
||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
peerFingerprintsByPeerID[peerID]
|
||||
}
|
||||
|
||||
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
|
||||
if let fingerprint {
|
||||
peerFingerprintsByPeerID[peerID] = fingerprint
|
||||
} else {
|
||||
peerFingerprintsByPeerID.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
|
||||
peerFingerprintsByPeerID = mappings
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateFingerprintMapping(
|
||||
from oldPeerID: PeerID,
|
||||
to newPeerID: PeerID,
|
||||
fallback: String? = nil
|
||||
) -> String? {
|
||||
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
|
||||
if let fingerprint {
|
||||
peerFingerprintsByPeerID[newPeerID] = fingerprint
|
||||
if selectedPrivateChatFingerprint == nil {
|
||||
selectedPrivateChatFingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
|
||||
selectedPrivateChatFingerprint = fingerprint
|
||||
}
|
||||
|
||||
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||
encryptionStatusCache[peerID]
|
||||
}
|
||||
|
||||
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
|
||||
encryptionStatusCache[peerID] = status
|
||||
}
|
||||
|
||||
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||
if let peerID {
|
||||
encryptionStatusCache.removeValue(forKey: peerID)
|
||||
} else {
|
||||
encryptionStatusCache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||
encryptionStatuses[peerID]
|
||||
}
|
||||
|
||||
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
|
||||
if let status {
|
||||
encryptionStatuses[peerID] = status
|
||||
} else {
|
||||
encryptionStatuses.removeValue(forKey: peerID)
|
||||
}
|
||||
invalidateEncryptionCache(for: peerID)
|
||||
}
|
||||
|
||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||
verifiedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
func setVerified(_ fingerprint: String, verified: Bool) {
|
||||
if verified {
|
||||
verifiedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
verifiedFingerprints.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isVerified(_ fingerprint: String) -> Bool {
|
||||
verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
encryptionStatuses.removeAll()
|
||||
verifiedFingerprints.removeAll()
|
||||
peerFingerprintsByPeerID.removeAll()
|
||||
selectedPrivateChatFingerprint = nil
|
||||
stablePeerIDsByShortID.removeAll()
|
||||
encryptionStatusCache.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MeshPeerRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let displayName: String
|
||||
let isMe: Bool
|
||||
let hasUnread: Bool
|
||||
let isBlocked: Bool
|
||||
let isFavorite: Bool
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
let isMutualFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let showsVerifiedBadgeWhenOffline: Bool
|
||||
/// Vouched-for by someone I verified, without an explicit verification of
|
||||
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
||||
let showsVouchedBadge: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
struct GeohashPersonRow: Identifiable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let isMe: Bool
|
||||
let isTeleported: Bool
|
||||
let isBlocked: Bool
|
||||
}
|
||||
|
||||
struct GroupChatRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let name: String
|
||||
let memberCount: Int
|
||||
let isCreator: Bool
|
||||
let hasUnread: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@Published private(set) var renderID = ""
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversations: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private let locationPresenceStore: LocationPresenceStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversations: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil,
|
||||
locationPresenceStore: LocationPresenceStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversations = conversations
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||
self.allPeers = chatViewModel.allPeers
|
||||
|
||||
bind()
|
||||
refresh()
|
||||
}
|
||||
|
||||
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
||||
}
|
||||
|
||||
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
||||
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
||||
}
|
||||
|
||||
func participantCount(for geohash: String) -> Int {
|
||||
chatViewModel.geohashParticipantCount(for: geohash)
|
||||
}
|
||||
|
||||
func startConversation(with peerID: PeerID) {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
}
|
||||
|
||||
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||
}
|
||||
|
||||
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
chatViewModel.blockGeohashUser(
|
||||
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
chatViewModel.unblockGeohashUser(
|
||||
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] peers in
|
||||
self?.allPeers = peers
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationPresenceStore.$teleportedGeo
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversations.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.participantTracker.$visiblePeople
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let myPeerID = chatViewModel.meshService.myPeerID
|
||||
let meshRows = allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||
// Vouched is subordinate to verified: never show both seals.
|
||||
let vouchedBadge = !isVerifiedFingerprint
|
||||
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||
isMe: isMe,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||
isConnected: peer.isConnected,
|
||||
isReachable: peer.isReachable,
|
||||
isMutualFavorite: peer.isMutualFavorite,
|
||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||
showsVouchedBadge: vouchedBadge
|
||||
)
|
||||
}
|
||||
|
||||
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
||||
guard !row.isMe else { return }
|
||||
if row.isConnected {
|
||||
counts.connected += 1
|
||||
counts.reachable += 1
|
||||
} else if row.isReachable {
|
||||
counts.reachable += 1
|
||||
}
|
||||
}
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
} +
|
||||
groupRows.map {
|
||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
private func buildGroupRows() -> [GroupChatRow] {
|
||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||
return chatViewModel.groupStore.groups.map { group in
|
||||
GroupChatRow(
|
||||
peerID: group.peerID,
|
||||
name: group.name,
|
||||
memberCount: group.members.count,
|
||||
isCreator: group.creatorFingerprint == myFingerprint,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||
|
||||
return chatViewModel.visibleGeohashPeople().map { person in
|
||||
let isMe = person.id == myHex
|
||||
return GeohashPersonRow(
|
||||
id: person.id,
|
||||
displayName: person.displayName,
|
||||
isMe: isMe,
|
||||
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
||||
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentGeohashIdentityHex() -> String? {
|
||||
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return identity.publicKeyHex.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Feature model for private (direct) conversations.
|
||||
///
|
||||
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
|
||||
/// returns the peer's conversation backing array (no mirror dictionary), and
|
||||
/// the store's typed `changes` subject drives invalidation — a change in the
|
||||
/// SELECTED peer's conversation republishes this model, while appends to
|
||||
/// other private chats only surface through the unread set. Direct
|
||||
/// conversations are keyed by raw routing peer ID; the coordinators'
|
||||
/// ephemeral/stable mirroring guarantees the selected peer's key always
|
||||
/// holds the full timeline (see `ConversationID.directPeer`).
|
||||
@MainActor
|
||||
final class PrivateInboxModel: ObservableObject {
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||
|
||||
private let conversations: ConversationStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversations: ConversationStore) {
|
||||
self.conversations = conversations
|
||||
self.selectedPeerID = conversations.selectedPrivatePeerID
|
||||
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
guard let peerID else { return [] }
|
||||
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversations.$selectedPrivatePeerID
|
||||
.dropFirst()
|
||||
.sink { [weak self] peerID in
|
||||
guard let self, self.selectedPeerID != peerID else { return }
|
||||
self.selectedPeerID = peerID
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversations.changes
|
||||
.sink { [weak self] change in
|
||||
self?.apply(change)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func apply(_ change: ConversationChange) {
|
||||
switch change {
|
||||
case .appended(let id, _),
|
||||
.updated(let id, _),
|
||||
.statusChanged(let id, _, _),
|
||||
.messageRemoved(let id, _),
|
||||
.cleared(let id):
|
||||
republishIfSelected(id)
|
||||
|
||||
case .unreadChanged(let id, _):
|
||||
guard isDirect(id) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
|
||||
case .removed(let id):
|
||||
guard isDirect(id) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
republishIfSelected(id)
|
||||
|
||||
case .migrated(let source, let destination):
|
||||
guard isDirect(source) || isDirect(destination) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
republishIfSelected(source)
|
||||
republishIfSelected(destination)
|
||||
}
|
||||
}
|
||||
|
||||
private func republishIfSelected(_ id: ConversationID) {
|
||||
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
private func refreshUnreadPeerIDs() {
|
||||
let next = conversations.unreadDirectRoutingPeerIDs()
|
||||
guard unreadPeerIDs != next else { return }
|
||||
unreadPeerIDs = next
|
||||
}
|
||||
|
||||
private func isDirect(_ id: ConversationID) -> Bool {
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum PrivateConversationAvailability: Equatable {
|
||||
case bluetoothConnected
|
||||
case meshReachable
|
||||
case nostrAvailable
|
||||
case offline
|
||||
}
|
||||
|
||||
struct PrivateConversationHeaderState: Equatable {
|
||||
let conversationPeerID: PeerID
|
||||
let headerPeerID: PeerID
|
||||
let displayName: String
|
||||
let availability: PrivateConversationAvailability
|
||||
let isFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||
}
|
||||
|
||||
/// Group chats have no single peer identity behind the header: no
|
||||
/// fingerprint screen, no per-peer encryption badge.
|
||||
var isGroupConversation: Bool {
|
||||
conversationPeerID.isGroup
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PrivateConversationModel: ObservableObject {
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversations: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversations: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversations = conversations
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
let initialPeerID = conversations.selectedPrivatePeerID
|
||||
self.selectedPeerID = initialPeerID
|
||||
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func startConversation(with peerID: PeerID) {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func openConversation(for peerID: PeerID) {
|
||||
if peerID.isGeoChat {
|
||||
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
||||
} else {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func endConversation() {
|
||||
chatViewModel.endPrivateChat()
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func toggleFavoriteForSelectedConversation() {
|
||||
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
||||
toggleFavorite(peerID: headerPeerID)
|
||||
}
|
||||
|
||||
func markMessagesAsRead(from peerID: PeerID) {
|
||||
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversations.$selectedPrivatePeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshSelectedConversation() {
|
||||
selectedPeerID = conversations.selectedPrivatePeerID
|
||||
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||
// the header; availability reads as mesh since group traffic floods
|
||||
// the local mesh, and the per-peer encryption badge does not apply.
|
||||
if conversationPeerID.isGroup {
|
||||
let displayName: String
|
||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||
displayName = "#\(group.name) (\(group.members.count))"
|
||||
} else {
|
||||
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: conversationPeerID,
|
||||
displayName: displayName,
|
||||
availability: .meshReachable,
|
||||
isFavorite: false,
|
||||
encryptionStatus: nil
|
||||
)
|
||||
}
|
||||
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: headerPeerID,
|
||||
displayName: displayName,
|
||||
availability: availability,
|
||||
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
||||
encryptionStatus: encryptionStatus
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(
|
||||
for conversationPeerID: PeerID,
|
||||
headerPeerID: PeerID,
|
||||
peer: BitchatPeer?
|
||||
) -> String {
|
||||
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||
}
|
||||
if let displayName = peer?.displayName {
|
||||
return displayName
|
||||
}
|
||||
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
||||
return nickname
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
||||
for: Data(hexString: headerPeerID.id) ?? Data()
|
||||
), !favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let identity = candidates.first,
|
||||
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
} else if let noiseKey = headerPeerID.noiseKey {
|
||||
let fingerprint = noiseKey.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
|
||||
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||
if let connectionState = peer?.connectionState {
|
||||
switch connectionState {
|
||||
case .bluetoothConnected:
|
||||
return .bluetoothConnected
|
||||
case .meshReachable:
|
||||
return .meshReachable
|
||||
case .nostrAvailable:
|
||||
return .nostrAvailable
|
||||
case .offline:
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
|
||||
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
||||
return .meshReachable
|
||||
}
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
return .nostrAvailable
|
||||
}
|
||||
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||
return .bluetoothConnected
|
||||
}
|
||||
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Feature model for the active public (mesh/geohash) timeline.
|
||||
///
|
||||
/// Observes ONE `Conversation` object in the single-writer
|
||||
/// `ConversationStore` — the active channel's — so appends to background
|
||||
/// conversations (other geohashes, private chats) never invalidate it.
|
||||
/// `messages` reads the observed conversation's backing array directly;
|
||||
/// there is no mirror copy.
|
||||
@MainActor
|
||||
final class PublicChatModel: ObservableObject {
|
||||
@Published private(set) var activeChannel: ChannelID
|
||||
|
||||
/// The active public conversation's timeline.
|
||||
var messages: [BitchatMessage] { activeConversation.messages }
|
||||
|
||||
private let conversations: ConversationStore
|
||||
private var activeConversation: Conversation
|
||||
private var activeConversationCancellable: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversations: ConversationStore) {
|
||||
let channel = conversations.activeChannel
|
||||
self.conversations = conversations
|
||||
self.activeChannel = channel
|
||||
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||
|
||||
observeActiveConversation()
|
||||
bind()
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversations.$activeChannel
|
||||
.dropFirst()
|
||||
.sink { [weak self] channel in
|
||||
guard let self else { return }
|
||||
self.activeChannel = channel
|
||||
self.retargetActiveConversation(to: channel)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// The store replaces a conversation's object when it is removed
|
||||
// (panic clear); retarget to the fresh instance so the observation
|
||||
// never goes stale.
|
||||
conversations.changes
|
||||
.sink { [weak self] change in
|
||||
guard let self,
|
||||
case .removed(let id) = change,
|
||||
id == self.activeConversation.id else { return }
|
||||
self.retargetActiveConversation(to: self.activeChannel)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func retargetActiveConversation(to channel: ChannelID) {
|
||||
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||
guard conversation !== activeConversation else {
|
||||
// Same object (e.g. re-selected channel): keep the existing
|
||||
// observation, but `messages` may still differ from what views
|
||||
// last rendered, so republish.
|
||||
objectWillChange.send()
|
||||
return
|
||||
}
|
||||
objectWillChange.send()
|
||||
activeConversation = conversation
|
||||
observeActiveConversation()
|
||||
}
|
||||
|
||||
private func observeActiveConversation() {
|
||||
activeConversationCancellable = activeConversation.objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
struct FingerprintPresentationState: Equatable {
|
||||
let peerNickname: String
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let theirFingerprint: String?
|
||||
let myFingerprint: String
|
||||
let isVerified: Bool
|
||||
/// Number of currently-valid vouches from peers the user verified
|
||||
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
||||
let voucherCount: Int
|
||||
/// Display names of the (verified) vouchers, where known.
|
||||
let voucherNames: [String]
|
||||
|
||||
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
|
||||
var isVouched: Bool { voucherCount > 0 }
|
||||
|
||||
var canToggleVerification: Bool {
|
||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||
}
|
||||
}
|
||||
|
||||
enum VerificationScanOutcome: Equatable {
|
||||
case requested(String)
|
||||
case notFound
|
||||
case invalid
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class VerificationModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.selectedPeerID = privateConversationModel.selectedPeerID
|
||||
|
||||
bind(privateConversationModel: privateConversationModel)
|
||||
}
|
||||
|
||||
func myQRString() -> String {
|
||||
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
guard chatViewModel.beginQRVerification(with: qr) else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
return .requested(qr.nickname)
|
||||
}
|
||||
|
||||
func verifyFingerprint(for peerID: PeerID) {
|
||||
chatViewModel.verifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
func unverifyFingerprint(for peerID: PeerID) {
|
||||
chatViewModel.unverifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
func isVerified(peerID: PeerID) -> Bool {
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||
return peerIdentityStore.isVerified(fingerprint)
|
||||
}
|
||||
|
||||
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
|
||||
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
|
||||
// Vouch state is recomputed on read: only vouchers still in the
|
||||
// verified set count, so removing a verification silently retires the
|
||||
// vouches that peer gave.
|
||||
let vouchers: [VouchRecord]
|
||||
if !isVerified, let theirFingerprint {
|
||||
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
|
||||
} else {
|
||||
vouchers = []
|
||||
}
|
||||
let voucherNames = vouchers.compactMap { record -> String? in
|
||||
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
|
||||
return nil
|
||||
}
|
||||
if let petname = social.localPetname, !petname.isEmpty { return petname }
|
||||
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
|
||||
}
|
||||
|
||||
return FingerprintPresentationState(
|
||||
peerNickname: peerNickname,
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: isVerified,
|
||||
voucherCount: vouchers.count,
|
||||
voucherNames: voucherNames
|
||||
)
|
||||
}
|
||||
|
||||
private func bind(privateConversationModel: PrivateConversationModel) {
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$currentNickname)
|
||||
|
||||
privateConversationModel.$selectedPeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedPeerID)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
|
||||
// are signalled via this notification rather than a published
|
||||
// property, so an open fingerprint sheet refreshes its vouched badge
|
||||
// live when a vouch batch is accepted.
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||
return peer.displayName
|
||||
}
|
||||
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
|
||||
return name
|
||||
}
|
||||
if let data = peerID.noiseKey {
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
let fingerprint = data.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,111 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_20x20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20x20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_60x60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_60x60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20x20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20x20@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_76x76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_76x76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_83.5x83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_1024x1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 460 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 564 B |
|
After Width: | Height: | Size: 668 B |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 597 B |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 641 B |
|
After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 765 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 628 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 976 B |
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "image-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"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" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 505 B |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -3,4 +3,4 @@
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,53 +11,67 @@ import UserNotifications
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||
static let groupID = "group.\(bundleID)"
|
||||
|
||||
@StateObject private var runtime: AppRuntime
|
||||
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
||||
@StateObject private var chatViewModel = ChatViewModel()
|
||||
#if os(iOS)
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
#elseif os(macOS)
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
|
||||
init() {
|
||||
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||
// Warm up georelay directory and refresh if stale (once/day)
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
|
||||
.environmentObject(runtime.publicChatModel)
|
||||
.environmentObject(runtime.privateInboxModel)
|
||||
.environmentObject(runtime.privateConversationModel)
|
||||
.environmentObject(runtime.verificationModel)
|
||||
.environmentObject(runtime.conversationUIModel)
|
||||
.environmentObject(runtime.locationChannelsModel)
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.environmentObject(chatViewModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
runtime.handleOpenURL(url)
|
||||
handleURL(url)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
runtime.handleScenePhaseChange(newPhase)
|
||||
switch newPhase {
|
||||
case .background:
|
||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||
break
|
||||
case .active:
|
||||
// Restart services when becoming active
|
||||
chatViewModel.meshService.startServices()
|
||||
checkForSharedContent()
|
||||
case .inactive:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
runtime.handleDidBecomeActiveNotification()
|
||||
// Check for shared content when app becomes active
|
||||
checkForSharedContent()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
runtime.handleMacDidBecomeActiveNotification()
|
||||
// App became active
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -66,18 +80,62 @@ struct BitchatApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
if url.scheme == "bitchat" && url.host == "share" {
|
||||
// Handle shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkForSharedContent() {
|
||||
// Check app group for shared content from extension
|
||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only process if shared within configured window
|
||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||
|
||||
// Clear the shared content
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
// No need to force synchronize here
|
||||
|
||||
// Send the shared content immediately on the main queue
|
||||
DispatchQueue.main.async {
|
||||
if contentType == "url" {
|
||||
// Try to parse as JSON first
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
// Send plain URL
|
||||
self.chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
// Fallback to simple URL
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
} else {
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
runtime?.applicationWillTerminate()
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -85,51 +143,80 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
runtime?.applicationWillTerminate()
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
}
|
||||
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
return true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NotificationDelegate()
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let identifier = response.notification.request.identifier
|
||||
let actionIdentifier = response.actionIdentifier
|
||||
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
|
||||
self.runtime?.handleNotificationResponse(
|
||||
identifier: identifier,
|
||||
actionIdentifier: actionIdentifier,
|
||||
userInfo: userInfo
|
||||
)
|
||||
completionHandler()
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle deeplink (e.g., geohash activity)
|
||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||
#else
|
||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
Task {
|
||||
let options = await self.runtime?.presentationOptions(
|
||||
forNotificationIdentifier: identifier,
|
||||
userInfo: userInfo
|
||||
) ?? [.banner, .sound]
|
||||
completionHandler(options)
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#else
|
||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func validateImageSource(at url: URL) throws {
|
||||
// Security H1: Check file size BEFORE reading into memory.
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int,
|
||||
fileSize > 0,
|
||||
fileSize <= maxSourceImageBytes else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
|
||||
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
|
||||
CGImageSourceGetType(source) != nil else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
|
||||
// Draw into a new context to get a clean CGImage without metadata
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||
let scaledImage = NSImage(size: newSize)
|
||||
scaledImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||
from: NSRect(origin: .zero, size: size),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||
|
||||
let directory: URL
|
||||
if let outputDirectory {
|
||||
directory = outputDirectory
|
||||
} else {
|
||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func applicationFilesDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
@@ -1,175 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
|
||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published private(set) var isPlaying: Bool = false
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 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"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
return Int(duration.rounded())
|
||||
}
|
||||
|
||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||
var remainingSeconds: Int {
|
||||
let remaining = max(0, duration - currentTime)
|
||||
return min(roundedDuration, Int(ceil(remaining)))
|
||||
}
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
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,
|
||||
sessionCoordinator: AudioSessionCoordinator? = nil,
|
||||
exclusivity: VoiceNotePlaybackCoordinator? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.sessionCoordinatorOverride = sessionCoordinator
|
||||
self.exclusivity = exclusivity ?? .shared
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
|
||||
func loadDuration() {
|
||||
guard duration == 0 else { return }
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||
let loadedDuration = player.duration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, self.duration == 0 else { return }
|
||||
self.duration = loadedDuration
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
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) {
|
||||
guard url != self.url else { return }
|
||||
stop()
|
||||
self.url = url
|
||||
player = nil
|
||||
duration = 0
|
||||
// Duration will be loaded on demand when needed
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
exclusivity.activate(self)
|
||||
isPlaying = true
|
||||
startTimer()
|
||||
updateProgress()
|
||||
// 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() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player?.currentTime = 0
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
exclusivity.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
guard ensurePlayerReady() else { return }
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
// While the session acquire is still in flight, don't start
|
||||
// audio pre-activation — the pending acquire's completion starts
|
||||
// playback (from the new position) once the session resolves.
|
||||
if isPlaying, !sessionAcquireInFlight {
|
||||
startPreparedPlayer()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
self.releaseSession()
|
||||
self.exclusivity.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// 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 {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
} catch {
|
||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
player = nil
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePlayerReady() -> Bool {
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
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() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
self?.updateProgress()
|
||||
}
|
||||
if let timer = timer {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
guard let player = player else {
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
progress = 0
|
||||
return
|
||||
}
|
||||
currentTime = player.currentTime
|
||||
duration = player.duration
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
struct Reservation: Equatable {
|
||||
fileprivate let generation: UInt64
|
||||
}
|
||||
|
||||
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 {
|
||||
activeController = nil
|
||||
}
|
||||
if latestReservedController === controller {
|
||||
latestReservedController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import Foundation
|
||||
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.
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error, Equatable {
|
||||
case microphoneAccessDenied
|
||||
case recordingInProgress
|
||||
case failedToStartRecording
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
/// 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 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
|
||||
|
||||
nonisolated
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording(owner: RecordingOwner) async throws -> URL {
|
||||
if activeOwner != nil {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
guard permissionGranted() else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
|
||||
activeOwner = owner
|
||||
startInFlight = true
|
||||
|
||||
// The acquire suspends while the blocking session IPC runs on the
|
||||
// coordinator's queue (never this actor's thread or main).
|
||||
let token: AudioSessionCoordinator.Token
|
||||
do {
|
||||
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
|
||||
Task { await self?.handleSessionInterruption(for: owner) }
|
||||
}
|
||||
} catch {
|
||||
guard activeOwner === owner else {
|
||||
throw CancellationError()
|
||||
}
|
||||
startInFlight = false
|
||||
activeOwner = nil
|
||||
throw error
|
||||
}
|
||||
|
||||
// Actor reentrancy: release/cancel may have ended this hold while the
|
||||
// blocking session activation was still in progress.
|
||||
guard activeOwner === owner, startInFlight else {
|
||||
sessionCoordinator.release(token)
|
||||
throw CancellationError()
|
||||
}
|
||||
startInFlight = false
|
||||
sessionToken = token
|
||||
|
||||
var outputURL: URL?
|
||||
do {
|
||||
let newURL = try makeOutputURL()
|
||||
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
|
||||
currentURL = newURL
|
||||
return newURL
|
||||
} catch {
|
||||
releaseSessionToken()
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
if let outputURL {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(owner: RecordingOwner) async -> URL? {
|
||||
guard activeOwner === owner else { return nil }
|
||||
|
||||
// `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
|
||||
|
||||
if activeRecorder.isRecording, paddingInterval > 0 {
|
||||
if let waitForStopPadding = testingHooks.waitForStopPadding {
|
||||
await waitForStopPadding(paddingInterval)
|
||||
} else {
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
recorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
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 {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
|
||||
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
#if os(iOS)
|
||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
#else
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
|
||||
/// onto its own queue.
|
||||
private func releaseSessionToken() {
|
||||
guard let token = sessionToken else { return }
|
||||
sessionToken = nil
|
||||
sessionCoordinator.release(token)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||
final class WaveformCache {
|
||||
static let shared = WaveformCache()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||
|
||||
private init() {}
|
||||
|
||||
func cachedWaveform(for url: URL) -> [Float]? {
|
||||
queue.sync {
|
||||
guard let entry = cache[url] else { return nil }
|
||||
return entry.waveform
|
||||
}
|
||||
}
|
||||
|
||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check cache (read-only, no update needed on cache hit for performance)
|
||||
if let entry = self.cache[url] {
|
||||
DispatchQueue.main.async { completion(entry.waveform) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Evict oldest entry if cache is full
|
||||
if self.cache.count >= self.maxCacheSize {
|
||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
self.cache.removeValue(forKey: oldest.key)
|
||||
}
|
||||
}
|
||||
|
||||
self.cache[url] = (computed, Date())
|
||||
}
|
||||
DispatchQueue.main.async { completion(computed) }
|
||||
}
|
||||
}
|
||||
|
||||
func purge(url: URL) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeValue(forKey: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
return autoreleasepool {
|
||||
do {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let length = Int(audioFile.length)
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||
return nil
|
||||
}
|
||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||
guard let channelData = buffer.floatChannelData else { return nil }
|
||||
|
||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
let samplesPerBin = max(1, frameLength / bins)
|
||||
|
||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||
for bin in 0..<bins {
|
||||
let start = bin * samplesPerBin
|
||||
let end = min(frameLength, start + samplesPerBin)
|
||||
if start >= end { break }
|
||||
|
||||
var sum: Float = 0
|
||||
var sampleCount = 0
|
||||
for frame in start..<end {
|
||||
var sampleValue: Float = 0
|
||||
for channel in 0..<channelCount {
|
||||
sampleValue += fabsf(channelData[channel][frame])
|
||||
}
|
||||
sum += sampleValue / Float(channelCount)
|
||||
sampleCount += 1
|
||||
}
|
||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||
}
|
||||
|
||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||
}
|
||||
return magnitudes
|
||||
} catch {
|
||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,13 +81,14 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Three-Layer Identity Model
|
||||
|
||||
/// 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.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: String // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ enum HandshakeState {
|
||||
case initiated
|
||||
case inProgress
|
||||
case completed(fingerprint: String)
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||
@@ -107,6 +109,7 @@ struct CryptographicIdentity: Codable {
|
||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||
var signingPublicKey: Data? = nil
|
||||
let firstSeen: Date
|
||||
let lastHandshake: Date?
|
||||
}
|
||||
|
||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||
@@ -122,35 +125,11 @@ struct SocialIdentity: Codable {
|
||||
var notes: String?
|
||||
}
|
||||
|
||||
/// Trust ladder: unknown → casual → vouched → trusted → verified.
|
||||
///
|
||||
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
|
||||
/// value ("unknown", "casual", …), not by ordinal position, so inserting
|
||||
/// `vouched` mid-ladder cannot corrupt previously persisted values — every
|
||||
/// pre-existing case keeps the exact raw value it was written with. The
|
||||
/// `vouched` tier is additionally never persisted into `SocialIdentity`
|
||||
/// (it's recomputed on read from stored vouches), so downgraded builds never
|
||||
/// encounter the unfamiliar raw value.
|
||||
enum TrustLevel: String, Codable {
|
||||
case unknown
|
||||
case casual
|
||||
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
||||
/// Derived at read time — never written to persistent storage.
|
||||
case vouched
|
||||
case trusted
|
||||
case verified
|
||||
}
|
||||
|
||||
// MARK: - Vouching (transitive verification)
|
||||
|
||||
/// One accepted vouch: a peer I verified (the voucher) attested that they
|
||||
/// verified the vouchee. Validity is recomputed on read — a record only
|
||||
/// counts while its voucher remains in `verifiedFingerprints` and its
|
||||
/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher
|
||||
/// silently invalidates the vouches they gave without a cascade delete.
|
||||
struct VouchRecord: Codable, Equatable {
|
||||
let voucherFingerprint: String
|
||||
let timestamp: Date
|
||||
case unknown = "unknown"
|
||||
case casual = "casual"
|
||||
case trusted = "trusted"
|
||||
case verified = "verified"
|
||||
}
|
||||
|
||||
// MARK: - Identity Cache
|
||||
@@ -174,21 +153,26 @@ struct IdentityCache: Codable {
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
}
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
// MARK: - Identity Resolution
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
enum IdentityHint {
|
||||
case unknown
|
||||
case likelyKnown(fingerprint: String)
|
||||
case ambiguous(candidates: Set<String>)
|
||||
case verified(fingerprint: String)
|
||||
}
|
||||
|
||||
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
|
||||
var vouchBatchSentAt: [String: Date]? = nil
|
||||
// MARK: - Pending Actions
|
||||
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
struct PendingActions {
|
||||
var toggleFavorite: Bool?
|
||||
var setTrustLevel: TrustLevel?
|
||||
var setPetname: String?
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -90,145 +90,65 @@
|
||||
/// - Advanced conflict resolution
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
protocol SecureIdentityStateManagerProtocol {
|
||||
// MARK: Secure Loading/Saving
|
||||
func forceSave()
|
||||
|
||||
// MARK: Social Identity Management
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
|
||||
|
||||
// MARK: Cryptographic Identities
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
// MARK: Blocked Users Management
|
||||
func isBlocked(fingerprint: String) -> Bool
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool)
|
||||
|
||||
// MARK: Geohash (Nostr) Blocking
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
|
||||
func getBlockedNostrPubkeys() -> Set<String>
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
|
||||
// MARK: Verification
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
func isVerified(fingerprint: String) -> Bool
|
||||
func getVerifiedFingerprints() -> Set<String>
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||
func isVouched(fingerprint: String) -> Bool
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
/// Provides thread-safe access to identity mappings with encryption at rest.
|
||||
/// All identity data is stored encrypted in the device Keychain for security.
|
||||
final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
class SecureIdentityStateManager {
|
||||
static let shared = SecureIdentityStateManager()
|
||||
|
||||
private let keychain = KeychainManager.shared
|
||||
private let cacheKey = "bitchat.identityCache.v2"
|
||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Pending actions before handshake
|
||||
private var pendingActions: [String: PendingActions] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||
// no run loop, so saves never actually fired.)
|
||||
// Debouncing for keychain saves
|
||||
private var saveTimer: Timer?
|
||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
||||
private var pendingSave = false
|
||||
|
||||
|
||||
// Encryption key
|
||||
private let encryptionKey: SymmetricKey
|
||||
/// True when `encryptionKey` is a throwaway generated this session because the
|
||||
/// persisted key could not be read (device locked / access denied). In that
|
||||
/// state we must NOT persist (it would overwrite the real cache with data the
|
||||
/// next launch can't decrypt) and must NOT delete the existing cache.
|
||||
private let encryptionKeyIsEphemeral: Bool
|
||||
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
|
||||
// Retrieve (or, only on genuine first run, generate) the cache
|
||||
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||
// transient failure (device locked / access denied): the legacy
|
||||
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||
// new key deletes the existing one first — permanently orphaning the
|
||||
// encrypted cache on a launch that merely couldn't read the key.
|
||||
private init() {
|
||||
// Generate or retrieve encryption key from keychain
|
||||
let loadedKey: SymmetricKey
|
||||
let keyIsEphemeral: Bool
|
||||
|
||||
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||
case .success(let keyData):
|
||||
|
||||
// Try to load from keychain
|
||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||
loadedKey = SymmetricKey(data: keyData)
|
||||
keyIsEphemeral = false
|
||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||
|
||||
case .itemNotFound:
|
||||
// Genuine first run: generate and persist a new key.
|
||||
let newKey = SymmetricKey(size: .bits256)
|
||||
let keyData = newKey.withUnsafeBytes { Data($0) }
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||
loadedKey = newKey
|
||||
// If even the save failed, treat the key as ephemeral so we don't
|
||||
// later try to persist a cache the next launch can't read.
|
||||
keyIsEphemeral = !saved
|
||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||
|
||||
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
|
||||
// Transient/critical read failure. Do NOT overwrite the persisted
|
||||
// key. Use a session-only ephemeral key; the real key and cache are
|
||||
// left intact for a healthy launch.
|
||||
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
|
||||
SecureLogger.logKeyOperation("load", keyType: "identity cache encryption key", success: true)
|
||||
}
|
||||
// Generate new key if needed
|
||||
else {
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
keyIsEphemeral = true
|
||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||
// Save to keychain
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||
SecureLogger.logKeyOperation("generate", keyType: "identity cache encryption key", success: saved)
|
||||
}
|
||||
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
if !keyIsEphemeral {
|
||||
loadIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
forceSave()
|
||||
|
||||
// Load identity cache on init
|
||||
loadIdentityCache()
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
|
||||
private func loadIdentityCache() {
|
||||
func loadIdentityCache() {
|
||||
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
|
||||
// No existing cache, start fresh
|
||||
return
|
||||
@@ -239,57 +159,67 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||
} catch {
|
||||
cache = IdentityCache()
|
||||
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||
SecureLogger.warning(
|
||||
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||
category: .security
|
||||
)
|
||||
// Log error but continue with empty cache
|
||||
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
pendingSave = true
|
||||
performSave()
|
||||
deinit {
|
||||
// Force save any pending changes
|
||||
forceSave()
|
||||
}
|
||||
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
|
||||
func saveIdentityCache() {
|
||||
// Mark that we need to save
|
||||
pendingSave = true
|
||||
|
||||
// Cancel any existing timer
|
||||
saveTimer?.invalidate()
|
||||
|
||||
// Schedule a new save after the debounce interval
|
||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
||||
self?.performSave()
|
||||
}
|
||||
}
|
||||
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
SecureLogger.debug("Identity cache saved to keychain", category: .security)
|
||||
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
||||
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
|
||||
// Force immediate save (for app termination)
|
||||
func forceSave() {
|
||||
performSave()
|
||||
saveTimer?.invalidate()
|
||||
if pendingSave {
|
||||
performSave()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Resolution
|
||||
|
||||
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
|
||||
queue.sync {
|
||||
// Check if we have candidates based on nickname
|
||||
if let fingerprints = cache.nicknameIndex[claimedNickname] {
|
||||
if fingerprints.count == 1 {
|
||||
return .likelyKnown(fingerprint: fingerprints.first!)
|
||||
} else {
|
||||
return .ambiguous(candidates: fingerprints)
|
||||
}
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@@ -318,13 +248,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
let updated = CryptographicIdentity(
|
||||
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)
|
||||
} else {
|
||||
@@ -333,7 +271,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
@@ -362,26 +301,38 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve cryptographic identity by fingerprint
|
||||
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
||||
queue.sync { cryptographicIdentities[fingerprint] }
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
guard PeerIDResolver.isShortID(peerID) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
}
|
||||
}
|
||||
|
||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||
queue.sync {
|
||||
return Array(cache.socialIdentities.values)
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
// Update nickname index
|
||||
if let previousClaimedNickname,
|
||||
previousClaimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||
// Remove old nickname from index if changed
|
||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +395,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
@@ -496,13 +447,17 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
sessionStart: Date(),
|
||||
handshakeState: handshakeState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
@@ -514,50 +469,95 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func getHandshakeState(peerID: String) -> HandshakeState? {
|
||||
queue.sync {
|
||||
return ephemeralSessions[peerID]?.handshakeState
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pending Actions
|
||||
|
||||
func setPendingAction(peerID: String, action: PendingActions) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.pendingActions[peerID] = action
|
||||
}
|
||||
}
|
||||
|
||||
func applyPendingActions(peerID: String, fingerprint: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
guard let actions = self.pendingActions[peerID] else { return }
|
||||
|
||||
// Get or create social identity
|
||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Unknown",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
|
||||
// Apply pending actions
|
||||
if let toggleFavorite = actions.toggleFavorite {
|
||||
identity.isFavorite = toggleFavorite
|
||||
}
|
||||
if let trustLevel = actions.setTrustLevel {
|
||||
identity.trustLevel = trustLevel
|
||||
}
|
||||
if let petname = actions.setPetname {
|
||||
identity.localPetname = petname
|
||||
}
|
||||
|
||||
// Save updated identity
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
self.pendingActions.removeValue(forKey: peerID)
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
self.cryptographicIdentities.removeAll()
|
||||
self.pendingActions.removeAll()
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||
SecureLogger.logKeyOperation("delete", keyType: "identity cache", success: deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
func removeEphemeralSession(peerID: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
self.pendingActions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
verifiedAt[fingerprint] = Date()
|
||||
self.cache.verifiedAt = verifiedAt
|
||||
} else {
|
||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||
}
|
||||
|
||||
|
||||
// Update trust level if social identity exists
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.trustLevel = verified ? .verified : .casual
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
@@ -573,169 +573,4 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cache.verifiedFingerprints
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vouching (transitive verification)
|
||||
|
||||
/// Maximum vouchers retained per vouchee (most recent kept).
|
||||
static let maxVouchersPerVouchee = 8
|
||||
|
||||
/// Records an accepted vouch, enforcing every accept-policy gate that can
|
||||
/// be evaluated against stored state (signature verification is the
|
||||
/// caller's job — it needs the sender's announce-bound signing key):
|
||||
/// - the voucher must be a fingerprint *I* verified
|
||||
/// - self-vouches are ignored
|
||||
/// - vouches for peers I already verified are ignored (nothing to add)
|
||||
/// - attestations outside the validity window are ignored
|
||||
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
|
||||
///
|
||||
/// Returns true when the vouch was stored (or refreshed).
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||
recordVouch(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucherFingerprint: voucherFingerprint,
|
||||
timestamp: timestamp,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
|
||||
queue.sync(flags: .barrier) {
|
||||
guard voucheeFingerprint != voucherFingerprint,
|
||||
self.cache.verifiedFingerprints.contains(voucherFingerprint),
|
||||
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
|
||||
return false
|
||||
}
|
||||
let age = now.timeIntervalSince(timestamp)
|
||||
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
|
||||
return false
|
||||
}
|
||||
|
||||
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
|
||||
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
|
||||
let newest = max(records[index].timestamp, timestamp)
|
||||
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
|
||||
} else {
|
||||
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
||||
}
|
||||
// Keep the most recent vouchers up to the cap.
|
||||
records.sort { $0.timestamp > $1.timestamp }
|
||||
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
|
||||
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
|
||||
return false // Full of fresher vouches; nothing changed.
|
||||
}
|
||||
|
||||
var vouches = self.cache.vouchesByVouchee ?? [:]
|
||||
vouches[voucheeFingerprint] = capped
|
||||
self.cache.vouchesByVouchee = vouches
|
||||
self.saveIdentityCache()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The vouches that currently count for `fingerprint`. Validity is
|
||||
/// recomputed here rather than maintained by cascade deletes: a record
|
||||
/// only counts while its voucher is still verified-by-me and its
|
||||
/// timestamp is within the expiry window.
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||
validVouchers(for: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||
queue.sync {
|
||||
self.validVouchersLocked(for: fingerprint, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires `queue`.
|
||||
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
|
||||
return records.filter { record in
|
||||
record.voucherFingerprint != fingerprint
|
||||
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
|
||||
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the peer has at least one valid vouch and no explicit
|
||||
/// verification of ours.
|
||||
func isVouched(fingerprint: String) -> Bool {
|
||||
isVouched(fingerprint: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func isVouched(fingerprint: String, now: Date) -> Bool {
|
||||
queue.sync {
|
||||
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
|
||||
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// The trust level to display: explicit verification wins, then the
|
||||
/// persisted level, with `vouched` layered in (derived, never persisted)
|
||||
/// between `casual` and `trusted`.
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||
effectiveTrustLevel(for: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
|
||||
queue.sync {
|
||||
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
|
||||
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
||||
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||
switch stored {
|
||||
case .verified, .trusted:
|
||||
return stored
|
||||
case .vouched, .casual, .unknown:
|
||||
if vouched { return .vouched }
|
||||
// `.vouched` should never be persisted; degrade defensively.
|
||||
return stored == .vouched ? .casual : stored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
|
||||
}
|
||||
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||
queue.async(flags: .barrier) {
|
||||
var sentAt = self.cache.vouchBatchSentAt ?? [:]
|
||||
sentAt[fingerprint] = date
|
||||
self.cache.vouchBatchSentAt = sentAt
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
/// without a recorded verification time sort last), excluding the given
|
||||
/// fingerprint. Feeds the outgoing vouch batch.
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||
queue.sync {
|
||||
let verifiedAt = cache.verifiedAt ?? [:]
|
||||
let ordered = cache.verifiedFingerprints
|
||||
.filter { $0 != fingerprint }
|
||||
.sorted {
|
||||
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
|
||||
}
|
||||
return Array(ordered.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
||||
queue.sync { ephemeralSessions[peerID] }
|
||||
}
|
||||
|
||||
func debugLastInteraction(for fingerprint: String) -> Date? {
|
||||
queue.sync { cache.lastInteractions[fingerprint] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -31,22 +29,16 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<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>
|
||||
<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>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// BitchatMessage+Media.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
private struct Cache {
|
||||
let filesDir: URL?
|
||||
|
||||
static let shared = Cache()
|
||||
private init() {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAttachment(for nickname: String) -> Media? {
|
||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
||||
|
||||
func url(for category: MimeType.Category) -> URL? {
|
||||
guard content.hasPrefix(category.messagePrefix),
|
||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check outgoing first for sent messages, incoming for received
|
||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
||||
|
||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||
// Files are checked during playback/display, so missing files fail gracefully
|
||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
||||
return directory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
if let url = url(for: .audio) {
|
||||
return .voice(url)
|
||||
}
|
||||
if let url = url(for: .image) {
|
||||
return .image(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
struct BitchatPeer: Identifiable, Equatable {
|
||||
let id: String // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
|
||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
@@ -73,16 +73,17 @@ struct BitchatPeer: Equatable {
|
||||
|
||||
// Initialize from mesh service data
|
||||
init(
|
||||
peerID: PeerID,
|
||||
id: String,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen _: Date = Date(),
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.id = id
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
|
||||
@@ -92,6 +93,8 @@ struct BitchatPeer: Equatable {
|
||||
}
|
||||
|
||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||
lhs.peerID == rhs.peerID
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// CommandsInfo.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||
// the suggestion panel is the app's only command-discovery surface, and
|
||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||
case block
|
||||
case clear
|
||||
case group
|
||||
case help
|
||||
case hug
|
||||
case message = "msg"
|
||||
case slap
|
||||
case pay
|
||||
case unblock
|
||||
case who
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
case ping
|
||||
case trace
|
||||
case drop
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .group:
|
||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||
case .drop:
|
||||
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .group: String(localized: "content.commands.group")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .pay: String(localized: "content.commands.pay")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .ping: String(localized: "content.commands.ping")
|
||||
case .trace: String(localized: "content.commands.trace")
|
||||
case .drop: String(localized: "content.commands.drop")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||
// processor still allows it behind an explicit "public" confirm).
|
||||
// Payments make sense in every DM and in mesh public.
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// NoisePayload.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Helper to create typed Noise payloads
|
||||
struct NoisePayload {
|
||||
let type: NoisePayloadType
|
||||
let data: Data
|
||||
|
||||
/// Encode payload with type prefix
|
||||
func encode() -> Data {
|
||||
var encoded = Data()
|
||||
encoded.append(type.rawValue)
|
||||
encoded.append(data)
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decode payload from data
|
||||
static func decode(_ data: Data) -> NoisePayload? {
|
||||
// Ensure we have at least 1 byte for the type
|
||||
guard !data.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Safely get the first byte
|
||||
let firstByte = data[data.startIndex]
|
||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a proper Data copy (not a subsequence) for thread safety
|
||||
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
||||
return NoisePayload(type: type, data: payloadData)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// ReadReceipt.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: PeerID // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
self.readerNickname = readerNickname
|
||||
self.timestamp = Date()
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
self.readerNickname = readerNickname
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> ReadReceipt? {
|
||||
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Binary Encoding
|
||||
|
||||
func toBinaryData() -> Data {
|
||||
var data = Data()
|
||||
data.appendUUID(originalMessageID)
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID.id
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
readerData.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
while readerData.count < 8 {
|
||||
readerData.append(0)
|
||||
}
|
||||
data.append(readerData)
|
||||
data.appendDate(timestamp)
|
||||
data.appendString(readerNickname)
|
||||
return data
|
||||
}
|
||||
|
||||
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
||||
// Create defensive copy
|
||||
let dataCopy = Data(data)
|
||||
|
||||
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
||||
guard dataCopy.count >= 49 else { return nil }
|
||||
|
||||
var offset = 0
|
||||
|
||||
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = PeerID(hexData: readerIDData)
|
||||
guard readerID.isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
||||
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
||||
|
||||
return ReadReceipt(originalMessageID: originalMessageID,
|
||||
receiptID: receiptID,
|
||||
readerID: readerID,
|
||||
readerNickname: readerNickname,
|
||||
timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||
// streams (targeted resync for stalled reassemblies)
|
||||
struct RequestSyncPacket {
|
||||
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
||||
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
||||
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
||||
static let maxFragmentIdFilterCount = 60
|
||||
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
||||
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
||||
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
||||
let tokens = fragmentIDs
|
||||
.filter { $0.count == 8 }
|
||||
.prefix(maxFragmentIdFilterCount)
|
||||
.map { $0.hexEncodedString() }
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
return tokens.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
||||
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
||||
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
||||
guard let filter else { return nil }
|
||||
var ids: Set<Data> = []
|
||||
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
||||
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
||||
ids.insert(id)
|
||||
}
|
||||
return ids.isEmpty ? nil : ids
|
||||
}
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
self.sinceTimestamp = sinceTimestamp
|
||||
self.fragmentIdFilter = fragmentIdFilter
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
func putTLV(_ t: UInt8, _ v: Data) {
|
||||
out.append(t)
|
||||
let len = UInt16(v.count)
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(v)
|
||||
}
|
||||
// P
|
||||
putTLV(0x01, Data([UInt8(p & 0xFF)]))
|
||||
// M (uint32)
|
||||
var mBE = m.bigEndian
|
||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
if let ts = sinceTimestamp {
|
||||
var tsBE = ts.bigEndian
|
||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||
}
|
||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||
putTLV(0x06, fidData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
var sinceTimestamp: UInt64? = nil
|
||||
var fragmentIdFilter: String? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
guard off + 2 <= data.count else { return nil }
|
||||
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
|
||||
guard off + len <= data.count else { return nil }
|
||||
let v = data.subdata(in: off..<(off+len)); off += len
|
||||
switch t {
|
||||
case 0x01:
|
||||
if v.count == 1 { p = Int(v[0]) }
|
||||
case 0x02:
|
||||
if v.count == 4 {
|
||||
var mm: UInt32 = 0
|
||||
for b in v { mm = (mm << 8) | UInt32(b) }
|
||||
m = mm
|
||||
}
|
||||
case 0x03:
|
||||
if v.count > maxAcceptBytes { return nil }
|
||||
payload = v
|
||||
case 0x04:
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
case 0x05:
|
||||
if v.count == 8 {
|
||||
var ts: UInt64 = 0
|
||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
// Same acceptance cap as the GCS payload; an oversized filter
|
||||
// is ignored rather than failing the whole request.
|
||||
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//
|
||||
// NoiseHandshakeCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||
class NoiseHandshakeCoordinator {
|
||||
|
||||
// MARK: - Handshake State
|
||||
|
||||
enum HandshakeState: Equatable {
|
||||
case idle
|
||||
case waitingToInitiate(since: Date)
|
||||
case initiating(attempt: Int, lastAttempt: Date)
|
||||
case responding(since: Date)
|
||||
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
||||
case established(since: Date)
|
||||
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .idle, .established, .failed:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var handshakeStates: [String: HandshakeState] = [:]
|
||||
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
||||
|
||||
// Configuration
|
||||
private let maxHandshakeAttempts = 3
|
||||
private let handshakeTimeout: TimeInterval = 10.0
|
||||
private let retryDelay: TimeInterval = 2.0
|
||||
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
||||
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
|
||||
private let maxEstablishedSessions = 50 // Limit total established sessions
|
||||
|
||||
// Track handshake messages to detect duplicates
|
||||
private var processedHandshakeMessages: Set<Data> = []
|
||||
private let messageHistoryLimit = 100
|
||||
|
||||
// MARK: - Role Determination
|
||||
|
||||
/// Deterministically determine who should initiate the handshake
|
||||
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
||||
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
||||
// Use simple string comparison for deterministic ordering
|
||||
return myPeerID < remotePeerID ? .initiator : .responder
|
||||
}
|
||||
|
||||
/// Check if we should initiate handshake with a peer
|
||||
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
// Check if we're already in an active handshake
|
||||
if let state = handshakeStates[remotePeerID], state.isActive {
|
||||
// Check if the handshake is stale and we should force a new one
|
||||
if forceIfStale {
|
||||
switch state {
|
||||
case .initiating(_, let lastAttempt):
|
||||
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
|
||||
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
return true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)",
|
||||
category: SecureLogger.handshake, level: .debug)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check role
|
||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||
if role != .initiator {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we've failed recently and can't retry yet
|
||||
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
||||
if !canRetry {
|
||||
return false
|
||||
}
|
||||
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we're initiating a handshake
|
||||
func recordHandshakeInitiation(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
||||
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
||||
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we're responding to a handshake
|
||||
func recordHandshakeResponse(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates[peerID] = .responding(since: Date())
|
||||
SecureLogger.log("Recording handshake response to \(peerID)",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record successful handshake completion
|
||||
func recordHandshakeSuccess(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates[peerID] = .established(since: Date())
|
||||
SecureLogger.log("Handshake successfully established with \(peerID)",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record handshake failure
|
||||
func recordHandshakeFailure(peerID: String, reason: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let attempts = self.getCurrentAttempt(for: peerID)
|
||||
let canRetry = attempts < self.maxHandshakeAttempts
|
||||
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
||||
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should accept an incoming handshake initiation
|
||||
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
// If we're already established, reject new handshakes
|
||||
if case .established = handshakeStates[remotePeerID] {
|
||||
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established",
|
||||
category: SecureLogger.handshake, level: .debug)
|
||||
return false
|
||||
}
|
||||
|
||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||
|
||||
// If we're the initiator and already initiating, this is a race condition
|
||||
if role == .initiator {
|
||||
if case .initiating = handshakeStates[remotePeerID] {
|
||||
// They shouldn't be initiating, but accept it to recover from race condition
|
||||
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If we're the responder, we should accept
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a duplicate handshake message
|
||||
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
if processedHandshakeMessages.contains(data) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Add to processed messages with size limit
|
||||
if processedHandshakeMessages.count >= messageHistoryLimit {
|
||||
processedHandshakeMessages.removeAll()
|
||||
}
|
||||
processedHandshakeMessages.insert(data)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get time to wait before next handshake attempt
|
||||
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
||||
return handshakeQueue.sync {
|
||||
guard let state = handshakeStates[peerID] else { return nil }
|
||||
|
||||
switch state {
|
||||
case .failed(_, let canRetry, let lastAttempt):
|
||||
if !canRetry { return nil }
|
||||
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceFailure >= retryDelay {
|
||||
return 0
|
||||
}
|
||||
return retryDelay - timeSinceFailure
|
||||
|
||||
case .initiating(_, let lastAttempt):
|
||||
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
||||
return 0
|
||||
}
|
||||
return minTimeBetweenHandshakes - timeSinceAttempt
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset handshake state for a peer
|
||||
func resetHandshakeState(for peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates.removeValue(forKey: peerID)
|
||||
SecureLogger.log("Reset handshake state for \(peerID)",
|
||||
category: SecureLogger.handshake, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale handshake states and old established sessions
|
||||
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
|
||||
return handshakeQueue.sync {
|
||||
let now = Date()
|
||||
var stalePeerIDs: [String] = []
|
||||
var establishedSessions: [(peerID: String, since: Date)] = []
|
||||
|
||||
for (peerID, state) in handshakeStates {
|
||||
var isStale = false
|
||||
|
||||
switch state {
|
||||
case .initiating(_, let lastAttempt):
|
||||
if now.timeIntervalSince(lastAttempt) > staleTimeout {
|
||||
isStale = true
|
||||
}
|
||||
case .responding(let since):
|
||||
if now.timeIntervalSince(since) > staleTimeout {
|
||||
isStale = true
|
||||
}
|
||||
case .waitingForResponse(_, let timeout):
|
||||
if now > timeout {
|
||||
isStale = true
|
||||
}
|
||||
case .established(let since):
|
||||
// Track established sessions for potential cleanup
|
||||
establishedSessions.append((peerID, since))
|
||||
// Clean up very old established sessions
|
||||
if now.timeIntervalSince(since) > establishedSessionTTL {
|
||||
isStale = true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if isStale {
|
||||
stalePeerIDs.append(peerID)
|
||||
SecureLogger.log("Found stale handshake state for \(peerID): \(state)",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have too many established sessions, clean up the oldest ones
|
||||
if establishedSessions.count > maxEstablishedSessions {
|
||||
// Sort by age (oldest first)
|
||||
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
|
||||
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
|
||||
|
||||
for i in 0..<sessionsToRemove {
|
||||
let peerID = sortedSessions[i].peerID
|
||||
stalePeerIDs.append(peerID)
|
||||
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale states
|
||||
for peerID in stalePeerIDs {
|
||||
handshakeStates.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
if !stalePeerIDs.isEmpty {
|
||||
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
|
||||
return stalePeerIDs
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current handshake state
|
||||
func getHandshakeState(for peerID: String) -> HandshakeState {
|
||||
return handshakeQueue.sync {
|
||||
return handshakeStates[peerID] ?? .idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current retry count for a peer
|
||||
func getRetryCount(for peerID: String) -> Int {
|
||||
return handshakeQueue.sync {
|
||||
switch handshakeStates[peerID] {
|
||||
case .initiating(let attempt, _):
|
||||
return attempt - 1 // Attempts start at 1, retries start at 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment retry count for a peer
|
||||
func incrementRetryCount(for peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let currentAttempt = self.getCurrentAttempt(for: peerID)
|
||||
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func getCurrentAttempt(for peerID: String) -> Int {
|
||||
switch handshakeStates[peerID] {
|
||||
case .initiating(let attempt, _):
|
||||
return attempt
|
||||
case .failed(_, _, _):
|
||||
// Count previous attempts
|
||||
return 1 // Simplified for now
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Log current handshake states for debugging
|
||||
func logHandshakeStates() {
|
||||
handshakeQueue.sync {
|
||||
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug)
|
||||
for (peerID, state) in handshakeStates {
|
||||
let stateDesc: String
|
||||
switch state {
|
||||
case .idle:
|
||||
stateDesc = "idle"
|
||||
case .waitingToInitiate(let since):
|
||||
stateDesc = "waiting to initiate (since \(since))"
|
||||
case .initiating(let attempt, let lastAttempt):
|
||||
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
||||
case .responding(let since):
|
||||
stateDesc = "responding (since: \(since))"
|
||||
case .waitingForResponse(let messages, let timeout):
|
||||
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
|
||||
case .established(let since):
|
||||
stateDesc = "established (since \(since))"
|
||||
case .failed(let reason, let canRetry, let lastAttempt):
|
||||
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
||||
}
|
||||
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug)
|
||||
}
|
||||
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all handshake states - used during panic mode
|
||||
func clearAllHandshakeStates() {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning)
|
||||
self.handshakeStates.removeAll()
|
||||
self.processedHandshakeMessages.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,10 +77,9 @@
|
||||
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import os.log
|
||||
|
||||
// Core Noise Protocol implementation
|
||||
// Based on the Noise Protocol Framework specification
|
||||
@@ -93,7 +92,6 @@ enum NoisePattern {
|
||||
case XX // Most versatile, mutual authentication
|
||||
case IK // Initiator knows responder's static key
|
||||
case NK // Anonymous initiator
|
||||
case X // One-way: single message to a known static key (no response)
|
||||
}
|
||||
|
||||
enum NoiseRole {
|
||||
@@ -129,7 +127,7 @@ struct NoiseProtocolName {
|
||||
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
|
||||
/// and replay protection using a sliding window algorithm.
|
||||
/// - Warning: Nonce reuse would be catastrophic for security
|
||||
final class NoiseCipherState {
|
||||
class NoiseCipherState {
|
||||
// Constants for replay protection
|
||||
private static let NONCE_SIZE_BYTES = 4
|
||||
private static let REPLAY_WINDOW_SIZE = 1024
|
||||
@@ -167,23 +165,19 @@ final class NoiseCipherState {
|
||||
// MARK: - Sliding Window Replay Protection
|
||||
|
||||
/// Check if nonce is valid for replay protection
|
||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
||||
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||
return false // Too old, outside window
|
||||
}
|
||||
|
||||
|
||||
if receivedNonce > highestReceivedNonce {
|
||||
return true // Always accept newer nonces
|
||||
}
|
||||
|
||||
|
||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||
let byteIndex = offset / 8
|
||||
let bitIndex = offset % 8
|
||||
|
||||
|
||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||
}
|
||||
|
||||
@@ -228,7 +222,7 @@ final class NoiseCipherState {
|
||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Extract 4-byte nonce (big-endian)
|
||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||
@@ -239,18 +233,18 @@ final class NoiseCipherState {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Extract ciphertext (remaining bytes)
|
||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||
|
||||
|
||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||
}
|
||||
|
||||
|
||||
/// Convert nonce to 4-byte array (big-endian)
|
||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||
}
|
||||
@@ -279,7 +273,7 @@ final class NoiseCipherState {
|
||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||
// increment local nonce
|
||||
nonce += 1
|
||||
|
||||
|
||||
// Create combined payload: <nonce><ciphertext>
|
||||
let combinedPayload: Data
|
||||
if (useExtractedNonce) {
|
||||
@@ -291,9 +285,9 @@ final class NoiseCipherState {
|
||||
|
||||
// Log high nonce values that might indicate issues
|
||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
||||
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
|
||||
}
|
||||
|
||||
|
||||
return combinedPayload
|
||||
}
|
||||
|
||||
@@ -313,23 +307,16 @@ final class NoiseCipherState {
|
||||
if useExtractedNonce {
|
||||
// Extract nonce and ciphertext from combined payload
|
||||
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
|
||||
SecureLogger.debug("Decrypt failed: Could not extract nonce from payload")
|
||||
SecureLogger.log("Decrypt failed: Could not extract nonce from payload")
|
||||
throw NoiseError.invalidCiphertext
|
||||
}
|
||||
|
||||
// Validate nonce with sliding window replay protection
|
||||
guard isValidNonce(extractedNonce) else {
|
||||
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
// The 4-byte nonce prefix has been stripped, so the remaining bytes
|
||||
// must still hold at least the 16-byte Poly1305 tag. The up-front
|
||||
// `ciphertext.count >= 16` guard is not sufficient here (it counts
|
||||
// the nonce), and `prefix(count - 16)` would trap on a short payload.
|
||||
guard actualCiphertext.count >= 16 else {
|
||||
throw NoiseError.invalidCiphertext
|
||||
}
|
||||
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
@@ -355,26 +342,22 @@ final class NoiseCipherState {
|
||||
|
||||
// Log high nonce values that might indicate issues
|
||||
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
|
||||
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
|
||||
}
|
||||
|
||||
do {
|
||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||
|
||||
// BCH-01-010: Atomic nonce state update
|
||||
// Both replay window marking and nonce increment must complete together
|
||||
// to prevent state desynchronization. We perform both after successful
|
||||
// decryption only, ensuring state consistency on any failure path.
|
||||
|
||||
if useExtractedNonce {
|
||||
// Mark nonce as seen after successful decryption
|
||||
markNonceAsSeen(decryptionNonce)
|
||||
}
|
||||
nonce += 1
|
||||
|
||||
return plaintext
|
||||
} catch {
|
||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||
// Log authentication failures with nonce info
|
||||
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -393,16 +376,6 @@ final class NoiseCipherState {
|
||||
replayWindow[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setNonceForTesting(_ nonce: UInt64) {
|
||||
self.nonce = nonce
|
||||
}
|
||||
|
||||
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
||||
try extractNonceFromCiphertextPayload(combinedPayload)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Symmetric State
|
||||
@@ -411,7 +384,7 @@ final class NoiseCipherState {
|
||||
/// Responsible for key derivation, protocol name hashing, and maintaining
|
||||
/// the chaining key that provides key separation between handshake messages.
|
||||
/// - Note: This class implements the SymmetricState object from the Noise spec
|
||||
final class NoiseSymmetricState {
|
||||
class NoiseSymmetricState {
|
||||
private var cipherState: NoiseCipherState
|
||||
private var chainingKey: Data
|
||||
private var hash: Data
|
||||
@@ -424,7 +397,7 @@ final class NoiseSymmetricState {
|
||||
if nameData.count <= 32 {
|
||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||
} else {
|
||||
self.hash = nameData.sha256Hash()
|
||||
self.hash = Data(SHA256.hash(data: nameData))
|
||||
}
|
||||
self.chainingKey = self.hash
|
||||
}
|
||||
@@ -437,7 +410,7 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
|
||||
func mixHash(_ data: Data) {
|
||||
hash = (hash + data).sha256Hash()
|
||||
hash = Data(SHA256.hash(data: hash + data))
|
||||
}
|
||||
|
||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||
@@ -478,40 +451,17 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
}
|
||||
|
||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
||||
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||
let tempKey1 = SymmetricKey(data: output[0])
|
||||
let tempKey2 = SymmetricKey(data: output[1])
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
||||
// The chaining key and hash should not be retained after handshake completes
|
||||
clearSensitiveData()
|
||||
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||
|
||||
return (c1, c2)
|
||||
}
|
||||
|
||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
||||
/// Called after split() to clear chaining key and hash per Noise spec
|
||||
func clearSensitiveData() {
|
||||
// Clear chaining key by overwriting with zeros
|
||||
let chainingKeyCount = chainingKey.count
|
||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
||||
|
||||
// Clear hash by overwriting with zeros
|
||||
let hashCount = hash.count
|
||||
hash = Data(repeating: 0, count: hashCount)
|
||||
|
||||
// Clear the internal cipher state
|
||||
cipherState.clearSensitiveData()
|
||||
}
|
||||
|
||||
deinit {
|
||||
clearSensitiveData()
|
||||
}
|
||||
|
||||
|
||||
// HKDF implementation
|
||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||
@@ -538,10 +488,9 @@ final class NoiseSymmetricState {
|
||||
/// This is the main interface for establishing encrypted sessions between peers.
|
||||
/// Manages the handshake state machine, message patterns, and key derivation.
|
||||
/// - Important: Each handshake instance should only be used once
|
||||
final class NoiseHandshakeState {
|
||||
class NoiseHandshakeState {
|
||||
private let role: NoiseRole
|
||||
private let pattern: NoisePattern
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var symmetricState: NoiseSymmetricState
|
||||
|
||||
// Keys
|
||||
@@ -557,24 +506,9 @@ final class NoiseHandshakeState {
|
||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||
private var currentPattern = 0
|
||||
|
||||
// Test support: predetermined ephemeral keys for test vectors
|
||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
||||
private var prologueData: Data
|
||||
|
||||
init(
|
||||
role: NoiseRole,
|
||||
pattern: NoisePattern,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
||||
prologue: Data = Data(),
|
||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) {
|
||||
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
||||
self.role = role
|
||||
self.pattern = pattern
|
||||
self.keychain = keychain
|
||||
self.prologueData = prologue
|
||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
||||
|
||||
// Initialize static keys
|
||||
if let localKey = localStaticKey {
|
||||
@@ -595,18 +529,17 @@ final class NoiseHandshakeState {
|
||||
}
|
||||
|
||||
private func mixPreMessageKeys() {
|
||||
// Mix prologue
|
||||
symmetricState.mixHash(self.prologueData)
|
||||
// Mix prologue (empty for XX pattern normally)
|
||||
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||
// For XX pattern, no pre-message keys
|
||||
// For IK/NK patterns, we'd mix the responder's static key here
|
||||
switch pattern {
|
||||
case .XX:
|
||||
break // No pre-message keys
|
||||
case .IK, .NK, .X:
|
||||
case .IK, .NK:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
_ = symmetricState.getHandshakeHash()
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -615,20 +548,15 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var messageBuffer = Data()
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
for pattern in patterns {
|
||||
switch pattern {
|
||||
case .e:
|
||||
// Generate ephemeral key (or use predetermined key for tests)
|
||||
if let predetermined = predeterminedEphemeralKey {
|
||||
localEphemeralPrivate = predetermined
|
||||
predeterminedEphemeralKey = nil
|
||||
} else {
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
}
|
||||
// Generate ephemeral key
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||
@@ -651,7 +579,7 @@ final class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
|
||||
case .es:
|
||||
// DH(ephemeral, static) - direction depends on role
|
||||
@@ -661,20 +589,14 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
} else {
|
||||
guard let localStatic = localStaticPrivate,
|
||||
let remoteEphemeral = remoteEphemeralPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
}
|
||||
|
||||
case .se:
|
||||
@@ -685,20 +607,14 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
} else {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
let remoteStatic = remoteStaticPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
}
|
||||
|
||||
case .ss:
|
||||
@@ -711,7 +627,7 @@ final class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,12 +639,12 @@ final class NoiseHandshakeState {
|
||||
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 {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var buffer = message
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
@@ -745,7 +661,7 @@ final class NoiseHandshakeState {
|
||||
do {
|
||||
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
|
||||
} catch {
|
||||
SecureLogger.warning("Invalid ephemeral public key received", category: .security)
|
||||
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning)
|
||||
throw NoiseError.invalidMessage
|
||||
}
|
||||
symmetricState.mixHash(ephemeralData)
|
||||
@@ -762,7 +678,7 @@ final class NoiseHandshakeState {
|
||||
let decrypted = try symmetricState.decryptAndHash(staticData)
|
||||
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
|
||||
} catch {
|
||||
SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
|
||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error)
|
||||
throw NoiseError.authenticationFailure
|
||||
}
|
||||
|
||||
@@ -787,11 +703,8 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
case .es:
|
||||
if role == .initiator {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
@@ -802,7 +715,7 @@ final class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localStatic = localStaticPrivate,
|
||||
let remoteEphemeral = remoteEphemeralPublic else {
|
||||
@@ -812,7 +725,7 @@ final class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .se:
|
||||
@@ -825,7 +738,7 @@ final class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
let remoteStatic = remoteStaticPublic else {
|
||||
@@ -835,7 +748,7 @@ final class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .ss:
|
||||
@@ -844,12 +757,9 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .e, .s:
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -858,20 +768,16 @@ final class NoiseHandshakeState {
|
||||
return currentPattern >= messagePatterns.count
|
||||
}
|
||||
|
||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
||||
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||
guard isHandshakeComplete() else {
|
||||
throw NoiseError.handshakeNotComplete
|
||||
}
|
||||
|
||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
||||
|
||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||
|
||||
|
||||
let (c1, c2) = symmetricState.split()
|
||||
|
||||
// Initiator uses c1 for sending, c2 for receiving
|
||||
// Responder uses c2 for sending, c1 for receiving
|
||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
||||
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||
}
|
||||
|
||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||
@@ -881,20 +787,6 @@ final class NoiseHandshakeState {
|
||||
func getHandshakeHash() -> Data {
|
||||
return symmetricState.getHandshakeHash()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
||||
try performDHOperation(pattern)
|
||||
}
|
||||
|
||||
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
||||
self.currentPattern = currentPattern
|
||||
}
|
||||
|
||||
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
||||
self.remoteEphemeralPublic = key
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Pattern Extensions
|
||||
@@ -905,7 +797,6 @@ extension NoisePattern {
|
||||
case .XX: return "XX"
|
||||
case .IK: return "IK"
|
||||
case .NK: return "NK"
|
||||
case .X: return "X"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,10 +818,6 @@ extension NoisePattern {
|
||||
[.e, .es], // -> e, es
|
||||
[.e, .ee] // <- e, ee
|
||||
]
|
||||
case .X:
|
||||
return [
|
||||
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,47 +838,22 @@ enum NoiseError: Error {
|
||||
case nonceExceeded
|
||||
}
|
||||
|
||||
// MARK: - Constant-Time Operations
|
||||
|
||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
||||
/// This function compares two Data objects in constant time, preventing
|
||||
/// information leakage via timing analysis.
|
||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
|
||||
var result: UInt8 = 0
|
||||
for i in 0..<a.count {
|
||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
||||
var result: UInt8 = 0
|
||||
for byte in data {
|
||||
result |= byte
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// MARK: - Key Validation
|
||||
|
||||
extension NoiseHandshakeState {
|
||||
/// Validate a Curve25519 public key
|
||||
/// Checks for weak/invalid keys that could compromise security
|
||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
// Check key length
|
||||
guard keyData.count == 32 else {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
||||
if constantTimeIsZero(keyData) {
|
||||
|
||||
// Check for all-zero key (point at infinity)
|
||||
if keyData.allSatisfy({ $0 == 0 }) {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Check for low-order points that could enable small subgroup attacks
|
||||
// These are the known bad points for Curve25519
|
||||
let lowOrderPoints: [Data] = [
|
||||
@@ -1012,28 +874,20 @@ extension NoiseHandshakeState {
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||
]
|
||||
|
||||
// BCH-01-010: Constant-time check against known bad points
|
||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
||||
var foundBadPoint = false
|
||||
for badPoint in lowOrderPoints {
|
||||
if constantTimeCompare(keyData, badPoint) {
|
||||
foundBadPoint = true
|
||||
}
|
||||
}
|
||||
|
||||
if foundBadPoint {
|
||||
SecureLogger.warning("Low-order point detected", category: .security)
|
||||
|
||||
// Check against known bad points
|
||||
if lowOrderPoints.contains(keyData) {
|
||||
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning)
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Try to create the key - CryptoKit will validate curve points internally
|
||||
do {
|
||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||
return publicKey
|
||||
} catch {
|
||||
// If CryptoKit rejects it, it's invalid
|
||||
SecureLogger.warning("CryptoKit validation failed", category: .security)
|
||||
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning)
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// NoiseRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// NoiseSecurityConsiderations.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
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
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
|
||||
// MARK: - Security Validations
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
|
||||
class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityConstants.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
}
|
||||