mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:25:19 +00:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bd8a69c0 | ||
|
|
629bea7fd0 | ||
|
|
1cf4ea9e57 | ||
|
|
3b96d8590d | ||
|
|
6cbfe8ecfb | ||
|
|
8481e1552d | ||
|
|
9ebaa9108c | ||
|
|
2b3264b581 | ||
|
|
830f7ee4b8 | ||
|
|
c8768c32b8 | ||
|
|
1352484491 | ||
|
|
ef396936a3 | ||
|
|
c3ce32ad07 | ||
|
|
d7ca99ae5d | ||
|
|
b25f0d9f63 | ||
|
|
abb998d809 | ||
|
|
d71befd8b7 | ||
|
|
78c4bed1ad | ||
|
|
c52a2a7772 | ||
|
|
3df0aa0cc4 | ||
|
|
46caded099 | ||
|
|
a04ac9dabd | ||
|
|
1825d115bc | ||
|
|
9754881af8 | ||
|
|
59cc857fde | ||
|
|
f0ca1b27c2 | ||
|
|
5beff8b4dc | ||
|
|
ba49b885bb | ||
|
|
ac10e906c0 | ||
|
|
119e882574 | ||
|
|
0acbbdf2e3 | ||
|
|
40fe0c8ba5 | ||
|
|
7593053137 | ||
|
|
4dde063027 | ||
|
|
8c4fead945 | ||
|
|
2543277755 | ||
|
|
a8ce4cbf64 | ||
|
|
93aa714ed4 | ||
|
|
9c7bf26e13 | ||
|
|
c29c7d83fe | ||
|
|
7f743d48fe | ||
|
|
27c39332d5 | ||
|
|
53c08d6807 | ||
|
|
7efe0a23b1 | ||
|
|
8dedc81512 | ||
|
|
5cbe9d4aa7 | ||
|
|
515fae9e25 | ||
|
|
0946d3921f | ||
|
|
1d40950118 | ||
|
|
b404d48ba6 | ||
|
|
2f1ba27d51 | ||
|
|
23124b36b7 | ||
|
|
3e8d05fa2e | ||
|
|
c36b582209 | ||
|
|
3c66ca7499 | ||
|
|
a042576652 | ||
|
|
3c120042a4 | ||
|
|
4e3603d5e4 | ||
|
|
f7ad970a83 | ||
|
|
4cc87633e1 | ||
|
|
2d565ad918 | ||
|
|
b4cb4d36f1 | ||
|
|
7fcef2a9cb | ||
|
|
e7dc0e0c5a | ||
|
|
3b2c7d2f73 | ||
|
|
209f926990 | ||
|
|
ef8b509ea5 | ||
|
|
06b5bdb6da | ||
|
|
a5a3efebcb | ||
|
|
cd39d8c8c0 | ||
|
|
1046bfec0f | ||
|
|
d8c83e25f4 | ||
|
|
a9fa614416 | ||
|
|
ecbbc23862 |
@@ -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
|
|
||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -18,11 +17,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
|
|||||||
@@ -5,146 +5,23 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Swift Tests (${{ matrix.name }})
|
name: Run Swift Tests
|
||||||
runs-on: macos-latest
|
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).
|
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Compute Swift cache salt
|
- name: Set up Swift
|
||||||
id: swift_cache_salt
|
uses: swift-actions/setup-swift@v2
|
||||||
run: |
|
|
||||||
{
|
|
||||||
swift --version
|
|
||||||
xcrun --show-sdk-platform-path
|
|
||||||
xcrun --show-sdk-version
|
|
||||||
xcodebuild -version
|
|
||||||
} | shasum -a 256 | awk '{ print "value=" $1 }' >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache build artifacts
|
- name: Build the package
|
||||||
uses: actions/cache@v4
|
run: swift build
|
||||||
with:
|
|
||||||
path: ${{ matrix.path }}/.build
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ steps.swift_cache_salt.outputs.value }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ steps.swift_cache_salt.outputs.value }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
|
||||||
${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ steps.swift_cache_salt.outputs.value }}-
|
|
||||||
|
|
||||||
- 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
|
- name: Run Tests
|
||||||
# Perf benchmarks are excluded here and run in their own serial step
|
run: swift test --parallel
|
||||||
# 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.
|
|
||||||
- name: Performance floor gate
|
|
||||||
if: matrix.name == 'app'
|
|
||||||
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 above only compile the macOS slice; this job covers the
|
|
||||||
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
|
||||||
ios-build:
|
|
||||||
name: Build iOS app (simulator)
|
|
||||||
runs-on: macos-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Build iOS (simulator, no signing)
|
|
||||||
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
|
||||||
run: |
|
|
||||||
set -o pipefail
|
|
||||||
xcodebuild -project bitchat.xcodeproj \
|
|
||||||
-scheme "bitchat (iOS)" \
|
|
||||||
-sdk iphonesimulator \
|
|
||||||
-destination 'generic/platform=iOS Simulator' \
|
|
||||||
ARCHS=arm64 \
|
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
|
||||||
build
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ plans/
|
|||||||
## AI
|
## AI
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.claude/
|
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.3
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
@@ -9,4 +9,3 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
|
|||||||
CODE_SIGN_STYLE = Automatic
|
CODE_SIGN_STYLE = Automatic
|
||||||
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||||
APP_GROUP_ID = group.chat.bitchat
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ patch-for-macos: backup
|
|||||||
# Build the macOS app
|
# Build the macOS app
|
||||||
build: #check generate
|
build: #check generate
|
||||||
@echo "Building BitChat for macOS..."
|
@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
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
|
|
||||||
# Run the macOS app
|
# Run the macOS app
|
||||||
run: build
|
run: build
|
||||||
|
|||||||
+16
-42
@@ -1,6 +1,6 @@
|
|||||||
# bitchat Privacy Policy
|
# bitchat Privacy Policy
|
||||||
|
|
||||||
*Last updated: June 2026*
|
*Last updated: January 2025*
|
||||||
|
|
||||||
## Our Commitment
|
## Our Commitment
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
- **No servers** - Everything happens on your device and through peer-to-peer connections
|
||||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||||
- **Open source** - You can verify these claims by reading our code
|
- **Open source** - You can verify these claims by reading our code
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
|
|
||||||
### On Your Device Only
|
### On Your Device Only
|
||||||
|
|
||||||
1. **Identity Keys**
|
1. **Identity Key**
|
||||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
- A cryptographic key generated on first launch
|
||||||
- Stored locally in your device's secure storage
|
- Stored locally in your device's secure storage
|
||||||
- Allows you to maintain "favorite" relationships across app restarts
|
- Allows you to maintain "favorite" relationships across app restarts
|
||||||
- Private keys never leave your device; public keys are shared when needed for messaging
|
- Never leaves your device
|
||||||
|
|
||||||
2. **Nickname**
|
2. **Nickname**
|
||||||
- The display name you choose (or auto-generated)
|
- The display name you choose (or auto-generated)
|
||||||
@@ -38,19 +38,12 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
- Stored only on your device
|
- Stored only on your device
|
||||||
- Allows you to recognize these peers in future sessions
|
- Allows you to recognize these peers in future sessions
|
||||||
|
|
||||||
5. **Optional Location Channel State**
|
|
||||||
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
|
|
||||||
- Stored locally on your device so the location-channel UI can restore your choices
|
|
||||||
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
|
|
||||||
- Exact latitude and longitude are not persisted by bitchat
|
|
||||||
|
|
||||||
### Temporary Session Data
|
### Temporary Session Data
|
||||||
|
|
||||||
During each session, bitchat temporarily maintains:
|
During each session, bitchat temporarily maintains:
|
||||||
- Active peer connections (forgotten when app closes)
|
- Active peer connections (forgotten when app closes)
|
||||||
- Routing information for message delivery
|
- Routing information for message delivery
|
||||||
- Cached messages for offline peers (12 hours max)
|
- Cached messages for offline peers (12 hours max)
|
||||||
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
|
|
||||||
|
|
||||||
## What Information is Shared
|
## What Information is Shared
|
||||||
|
|
||||||
@@ -69,21 +62,13 @@ When you join a password-protected room:
|
|||||||
- Your nickname appears in the member list
|
- Your nickname appears in the member list
|
||||||
- Room owners can see you've joined
|
- Room owners can see you've joined
|
||||||
|
|
||||||
### With Nostr Relays (Optional Features)
|
|
||||||
|
|
||||||
If you enable Nostr-backed features:
|
|
||||||
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
|
|
||||||
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
|
|
||||||
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
|
|
||||||
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
|
|
||||||
|
|
||||||
## What We DON'T Do
|
## What We DON'T Do
|
||||||
|
|
||||||
bitchat **never**:
|
bitchat **never**:
|
||||||
- Collects personal information
|
- Collects personal information
|
||||||
- Sells or shares your exact GPS location
|
- Tracks your location
|
||||||
- Stores data on servers we operate
|
- Stores data on servers
|
||||||
- Sells your data to advertisers or data brokers
|
- Shares data with third parties
|
||||||
- Uses analytics or telemetry
|
- Uses analytics or telemetry
|
||||||
- Creates user profiles
|
- Creates user profiles
|
||||||
- Requires registration
|
- Requires registration
|
||||||
@@ -99,27 +84,19 @@ All private messages use end-to-end encryption:
|
|||||||
## Your Rights
|
## Your Rights
|
||||||
|
|
||||||
You have complete control:
|
You have complete control:
|
||||||
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
|
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
||||||
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
|
- **Leave Anytime**: Close the app and your presence disappears
|
||||||
- **No Account**: No account record exists for you to delete from us
|
- **No Account**: Nothing to delete from servers because there are none
|
||||||
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
|
- **Portability**: Your data never leaves your device unless you export it
|
||||||
|
|
||||||
## Bluetooth & Permissions
|
## Bluetooth & Permissions
|
||||||
|
|
||||||
bitchat requires Bluetooth permission to function:
|
bitchat requires Bluetooth permission to function:
|
||||||
- Used only for peer-to-peer communication
|
- Used only for peer-to-peer communication
|
||||||
|
- No location data is accessed or stored
|
||||||
- Bluetooth is not used for tracking
|
- Bluetooth is not used for tracking
|
||||||
- You can revoke this permission at any time in system settings
|
- You can revoke this permission at any time in system settings
|
||||||
|
|
||||||
## Location Permission
|
|
||||||
|
|
||||||
Location permission is optional and is used only for location channels:
|
|
||||||
- Used to compute local geohash channels and display names
|
|
||||||
- Requested as when-in-use permission
|
|
||||||
- Exact coordinates are not shared in messages or stored by bitchat
|
|
||||||
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
|
|
||||||
- You can revoke this permission at any time in system settings
|
|
||||||
|
|
||||||
## Children's Privacy
|
## Children's Privacy
|
||||||
|
|
||||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||||
@@ -129,15 +106,12 @@ bitchat does not knowingly collect information from children. The app has no age
|
|||||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||||
- **Identity Key**: Persists until you delete the app
|
- **Identity Key**: Persists until you delete the app
|
||||||
- **Favorites**: Persist until you remove them or delete the app
|
- **Favorites**: Persist until you remove them or delete the app
|
||||||
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
|
|
||||||
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
|
|
||||||
- **Everything Else**: Exists only during active sessions
|
- **Everything Else**: Exists only during active sessions
|
||||||
|
|
||||||
## Security Measures
|
## Security Measures
|
||||||
|
|
||||||
- All communication is encrypted
|
- All communication is encrypted
|
||||||
- No accounts or company servers
|
- No data transmitted to servers (there are none)
|
||||||
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
|
|
||||||
- Open source code for public audit
|
- Open source code for public audit
|
||||||
- Regular security updates
|
- Regular security updates
|
||||||
- Cryptographic signatures prevent tampering
|
- Cryptographic signatures prevent tampering
|
||||||
@@ -147,7 +121,7 @@ bitchat does not knowingly collect information from children. The app has no age
|
|||||||
If we update this policy:
|
If we update this policy:
|
||||||
- The "Last updated" date will change
|
- The "Last updated" date will change
|
||||||
- The updated policy will be included in the app
|
- The updated policy will be included in the app
|
||||||
- No retroactive changes can make us collect data already held only in your app
|
- No retroactive changes can affect data (since we don't collect any)
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
@@ -158,7 +132,7 @@ bitchat is an open source project. For privacy questions:
|
|||||||
|
|
||||||
## Philosophy
|
## 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 company servers, no analytics. Just people talking freely.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-20
@@ -16,8 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/BitFoundation"),
|
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -26,19 +25,16 @@ let package = Package(
|
|||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.product(name: "Tor", package: "Arti")
|
.product(name: "Tor", package: "Tor")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"ViewModels/Extensions/README.md"
|
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localizable.xcstrings")
|
.process("Localizable.xcstrings")
|
||||||
@@ -46,24 +42,14 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: [
|
dependencies: ["bitchat"],
|
||||||
"bitchat",
|
|
||||||
.product(name: "BitFoundation", package: "BitFoundation")
|
|
||||||
],
|
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md",
|
"README.md"
|
||||||
// CI perf gate data (read by scripts/check-perf-floors.sh),
|
|
||||||
// not a test resource.
|
|
||||||
"Performance/perf-floors.json"
|
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.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)
|
📲 [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](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
Generated
+22
-72
@@ -10,12 +10,11 @@
|
|||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -96,24 +95,6 @@
|
|||||||
);
|
);
|
||||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||||
};
|
};
|
||||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
Localization/PrimaryLocalizationKeys.json,
|
|
||||||
README.md,
|
|
||||||
);
|
|
||||||
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
|
|
||||||
};
|
|
||||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
Localization/PrimaryLocalizationKeys.json,
|
|
||||||
README.md,
|
|
||||||
);
|
|
||||||
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -137,10 +118,6 @@
|
|||||||
};
|
};
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
|
|
||||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
|
|
||||||
);
|
|
||||||
path = bitchatTests;
|
path = bitchatTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -158,16 +135,15 @@
|
|||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
@@ -227,7 +203,6 @@
|
|||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -238,7 +213,6 @@
|
|||||||
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
5C22AA7B9ACC5A861445C769 /* Sources */,
|
5C22AA7B9ACC5A861445C769 /* Sources */,
|
||||||
C5E027A42ECCDFD700BD6012 /* Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -271,7 +245,6 @@
|
|||||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
865C8403EF02C089369A9FCB /* Sources */,
|
865C8403EF02C089369A9FCB /* Sources */,
|
||||||
C5E027A72ECCDFE200BD6012 /* Resources */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -308,7 +281,6 @@
|
|||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -321,7 +293,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 2650;
|
LastUpgradeCheck = 1640;
|
||||||
};
|
};
|
||||||
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -349,8 +321,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -372,16 +343,6 @@
|
|||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
C5E027A72ECCDFE200BD6012 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
};
|
|
||||||
CD6E8F32BC38357473954F97 /* Resources */ = {
|
CD6E8F32BC38357473954F97 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
@@ -446,6 +407,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -470,6 +432,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -496,6 +459,7 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -520,6 +484,7 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
@@ -551,7 +516,7 @@
|
|||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -561,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -585,6 +550,7 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -610,7 +576,7 @@
|
|||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -620,7 +586,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -646,6 +612,7 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -655,7 +622,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -668,7 +635,6 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -702,7 +668,6 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -720,7 +685,6 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
@@ -740,6 +704,7 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -749,7 +714,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -762,7 +727,6 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -796,7 +760,6 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -821,7 +784,6 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
@@ -838,6 +800,7 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
@@ -912,17 +875,13 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = localPackages/BitFoundation;
|
|
||||||
};
|
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/Arti;
|
relativePath = localPackages/Tor;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
@@ -931,8 +890,8 @@
|
|||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = exactVersion;
|
kind = upToNextMajorVersion;
|
||||||
version = 0.21.1;
|
minimumVersion = 0.21.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
@@ -943,15 +902,6 @@
|
|||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
productName = P256K;
|
productName = P256K;
|
||||||
};
|
};
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = BitFoundation;
|
|
||||||
};
|
|
||||||
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
|
|
||||||
productName = BitFoundation;
|
|
||||||
};
|
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitLogger;
|
productName = BitLogger;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "1640"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -98,8 +98,8 @@
|
|||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "BITCHAT_LOG_LEVEL"
|
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
value = "debug"
|
value = ""
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "1640"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,102 +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 stream() -> AsyncStream<AppEvent> {
|
|
||||||
let id = UUID()
|
|
||||||
return AsyncStream { continuation in
|
|
||||||
continuations[id] = continuation
|
|
||||||
continuation.onTermination = { [id] _ in
|
|
||||||
Task {
|
|
||||||
await self.removeContinuation(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func emit(_ event: AppEvent) {
|
|
||||||
for continuation in continuations.values {
|
|
||||||
continuation.yield(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish() {
|
|
||||||
for continuation in continuations.values {
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
continuations.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeContinuation(_ id: UUID) {
|
|
||||||
continuations.removeValue(forKey: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identity key for a direct conversation. Equality and hashing use the
|
|
||||||
/// 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,100 +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 showBluetoothAlert = false
|
|
||||||
@Published var bluetoothAlertMessage = ""
|
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
|
||||||
@Published var showScreenshotPrivacyWarning = false
|
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
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 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,388 +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 peerIdentityStore: PeerIdentityStore
|
|
||||||
let locationPresenceStore: LocationPresenceStore
|
|
||||||
let publicChatModel: PublicChatModel
|
|
||||||
let privateInboxModel: PrivateInboxModel
|
|
||||||
let privateConversationModel: PrivateConversationModel
|
|
||||||
let verificationModel: VerificationModel
|
|
||||||
let conversationUIModel: ConversationUIModel
|
|
||||||
let locationChannelsModel: LocationChannelsModel
|
|
||||||
let peerListModel: PeerListModel
|
|
||||||
let appChromeModel: AppChromeModel
|
|
||||||
|
|
||||||
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(),
|
|
||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
|
||||||
) {
|
|
||||||
self.idBridge = idBridge
|
|
||||||
let conversations = ConversationStore()
|
|
||||||
let peerIdentityStore = PeerIdentityStore()
|
|
||||||
let locationPresenceStore = LocationPresenceStore()
|
|
||||||
let locationManager = LocationChannelManager.shared
|
|
||||||
self.conversations = conversations
|
|
||||||
self.peerIdentityStore = peerIdentityStore
|
|
||||||
self.locationPresenceStore = locationPresenceStore
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
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, userInfo: [AnyHashable: Any]) {
|
|
||||||
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),
|
|
||||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
|
||||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
|
||||||
|
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
|
|
||||||
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,918 +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 }
|
|
||||||
|
|
||||||
switch (current, new) {
|
|
||||||
case (.read, .delivered), (.read, .sent):
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes every direct conversation (panic clear).
|
|
||||||
func removeAllDirectConversations() {
|
|
||||||
let directIDs = conversationIDs.filter { id in
|
|
||||||
if case .direct = id { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for id in directIDs {
|
|
||||||
removeConversation(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Diagnostics support
|
|
||||||
|
|
||||||
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,187 +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
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
chatViewModel.sendMessage("/block \(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch activeChannel {
|
|
||||||
case .mesh:
|
|
||||||
canSendMediaInCurrentContext = true
|
|
||||||
case .location:
|
|
||||||
canSendMediaInCurrentContext = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
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
|
|
||||||
|
|
||||||
private let manager: LocationChannelManager
|
|
||||||
private let network: NetworkActivationService
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
init(
|
|
||||||
manager: LocationChannelManager? = nil,
|
|
||||||
network: NetworkActivationService? = nil
|
|
||||||
) {
|
|
||||||
let manager = manager ?? .shared
|
|
||||||
let network = network ?? .shared
|
|
||||||
|
|
||||||
self.manager = manager
|
|
||||||
self.network = network
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,125 +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 replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
|
||||||
stablePeerIDsByShortID = mappings
|
|
||||||
}
|
|
||||||
|
|
||||||
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 replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
|
||||||
encryptionStatuses = statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
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,260 +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
|
|
||||||
|
|
||||||
var id: String { peerID.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GeohashPersonRow: Identifiable, Equatable {
|
|
||||||
let id: String
|
|
||||||
let displayName: String
|
|
||||||
let isMe: Bool
|
|
||||||
let isTeleported: Bool
|
|
||||||
let isBlocked: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 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)
|
|
||||||
|
|
||||||
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 verifiedBadge: Bool
|
|
||||||
if !isMe && !peer.isConnected,
|
|
||||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
|
||||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
|
||||||
} else {
|
|
||||||
verifiedBadge = 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
self.meshRows = meshRows
|
|
||||||
reachableMeshPeerCount = meshCounts.reachable
|
|
||||||
connectedMeshPeerCount = meshCounts.connected
|
|
||||||
self.geohashPeople = geohashPeople
|
|
||||||
visibleGeohashPeerCount = geohashPeople.count
|
|
||||||
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)"
|
|
||||||
}
|
|
||||||
).joined(separator: "|")
|
|
||||||
}
|
|
||||||
|
|
||||||
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,323 +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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
|
||||||
let availability = 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,152 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct FingerprintPresentationState: Equatable {
|
|
||||||
let statusPeerID: PeerID
|
|
||||||
let peerNickname: String
|
|
||||||
let encryptionStatus: EncryptionStatus
|
|
||||||
let theirFingerprint: String?
|
|
||||||
let myFingerprint: String
|
|
||||||
let isVerified: Bool
|
|
||||||
|
|
||||||
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 beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
|
||||||
chatViewModel.beginQRVerification(with: qr)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return FingerprintPresentationState(
|
|
||||||
statusPeerID: statusPeerID,
|
|
||||||
peerNickname: peerNickname,
|
|
||||||
encryptionStatus: encryptionStatus,
|
|
||||||
theirFingerprint: theirFingerprint,
|
|
||||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
|
||||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+184
-36
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -14,49 +15,115 @@ struct BitchatApp: App {
|
|||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
static let groupID = "group.\(bundleID)"
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var runtime: AppRuntime
|
@StateObject private var chatViewModel: ChatViewModel
|
||||||
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
// Skip the very first .active-triggered Tor restart on cold launch
|
||||||
|
@State private var didHandleInitialActive: Bool = false
|
||||||
|
@State private var didEnterBackground: Bool = false
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_runtime = StateObject(wrappedValue: AppRuntime())
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
|
_chatViewModel = StateObject(
|
||||||
|
wrappedValue: ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
|
.environmentObject(chatViewModel)
|
||||||
.environmentObject(runtime.publicChatModel)
|
|
||||||
.environmentObject(runtime.privateInboxModel)
|
|
||||||
.environmentObject(runtime.privateConversationModel)
|
|
||||||
.environmentObject(runtime.verificationModel)
|
|
||||||
.environmentObject(runtime.conversationUIModel)
|
|
||||||
.environmentObject(runtime.locationChannelsModel)
|
|
||||||
.environmentObject(runtime.peerListModel)
|
|
||||||
.environmentObject(runtime.appChromeModel)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||||
runtime.start()
|
// 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? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#elseif os(macOS)
|
||||||
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#endif
|
||||||
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
|
// Check for shared content
|
||||||
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
runtime.handleOpenURL(url)
|
handleURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
runtime.handleScenePhaseChange(newPhase)
|
switch newPhase {
|
||||||
|
case .background:
|
||||||
|
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||||
|
// Always send Tor to dormant on background for a clean restart later.
|
||||||
|
TorManager.shared.setAppForeground(false)
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
|
// Stop geohash sampling while backgrounded
|
||||||
|
Task { @MainActor in
|
||||||
|
chatViewModel.endGeohashSampling()
|
||||||
|
}
|
||||||
|
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
didEnterBackground = true
|
||||||
|
case .active:
|
||||||
|
// Restart services when becoming active
|
||||||
|
chatViewModel.meshService.startServices()
|
||||||
|
TorManager.shared.setAppForeground(true)
|
||||||
|
// On initial cold launch, Tor was just started in onAppear.
|
||||||
|
// Skip the deterministic restart the first time we become active.
|
||||||
|
if didHandleInitialActive && didEnterBackground {
|
||||||
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
didHandleInitialActive = true
|
||||||
|
}
|
||||||
|
didEnterBackground = false
|
||||||
|
if TorManager.shared.isAutoStartAllowed() {
|
||||||
|
Task.detached {
|
||||||
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
|
await MainActor.run {
|
||||||
|
// Rebuild proxied sessions to bind to the live Tor after readiness
|
||||||
|
TorURLSession.shared.rebuild()
|
||||||
|
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkForSharedContent()
|
||||||
|
case .inactive:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
runtime.handleDidBecomeActiveNotification()
|
// Check for shared content when app becomes active
|
||||||
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
runtime.handleMacDidBecomeActiveNotification()
|
// App became active
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -65,18 +132,62 @@ struct BitchatApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
#endif
|
#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: BitchatApp.groupID) 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)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var runtime: AppRuntime?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
true
|
return true
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
runtime?.applicationWillTerminate()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -85,29 +196,44 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var runtime: AppRuntime?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
runtime?.applicationWillTerminate()
|
chatViewModel?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var runtime: AppRuntime?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
Task { @MainActor in
|
// Check if this is a private message notification
|
||||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
if identifier.hasPrefix("private-") {
|
||||||
|
// Get peer ID from userInfo
|
||||||
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.chatViewModel?.startPrivateChat(with: PeerID(str: 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()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +241,34 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
Task {
|
// Check if this is a private message notification
|
||||||
let options = await self.runtime?.presentationOptions(
|
if identifier.hasPrefix("private-") {
|
||||||
forNotificationIdentifier: identifier,
|
// Get peer ID from userInfo
|
||||||
userInfo: userInfo
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
) ?? [.banner, .sound]
|
// Don't show notification if the private chat is already open
|
||||||
completionHandler(options)
|
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,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum ImageUtilsError: Error {
|
enum ImageUtilsError: Error {
|
||||||
@@ -13,67 +13,47 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 60_000
|
||||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
try validateImageSource(at: url)
|
// 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 else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
// Allow up to 10MB source images (will be scaled down)
|
||||||
|
guard fileSize <= 10 * 1024 * 1024 else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
#else
|
#else
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
#endif
|
#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)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
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
|
var quality = compressionQuality
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
||||||
throw ImageUtilsError.encodingFailed
|
throw ImageUtilsError.encodingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress to target size
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
quality -= 0.1
|
quality -= 0.1
|
||||||
autoreleasepool {
|
autoreleasepool {
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
if let next = scaled.jpegData(compressionQuality: quality) {
|
||||||
jpegData = next
|
jpegData = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -85,37 +65,14 @@ enum ImageUtils {
|
|||||||
guard maxSide > maxDimension else { return image }
|
guard maxSide > maxDimension else { return image }
|
||||||
let scale = maxDimension / maxSide
|
let scale = maxDimension / maxSide
|
||||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
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)
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
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
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
@@ -151,7 +108,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
let outputURL = try makeOutputURL()
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -173,7 +130,6 @@ enum ImageUtils {
|
|||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -181,9 +137,8 @@ enum ImageUtils {
|
|||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
// By only specifying compression quality and no metadata keys,
|
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
||||||
// we ensure a clean JPEG with no privacy-leaking information
|
|
||||||
let options: [CFString: Any] = [
|
let options: [CFString: Any] = [
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
]
|
]
|
||||||
@@ -195,17 +150,12 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
private static func makeOutputURL() throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||||
|
|
||||||
let directory: URL
|
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
if let outputDirectory {
|
|
||||||
directory = outputDirectory
|
|
||||||
} else {
|
|
||||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
|
||||||
}
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return directory.appendingPathComponent(fileName)
|
return directory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
@Published private(set) var duration: TimeInterval = 0
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
@Published private(set) var progress: Double = 0
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
/// 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 player: AVAudioPlayer?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var url: URL
|
private var url: URL
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
actor VoiceRecorder {
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
case recorderInitializationFailed
|
||||||
@@ -11,16 +12,20 @@ actor VoiceRecorder {
|
|||||||
|
|
||||||
static let shared = VoiceRecorder()
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
|
||||||
static let minRecordingDuration: TimeInterval = 1
|
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
|
private var stopWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
nonisolated
|
@discardableResult
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
@@ -41,8 +46,8 @@ actor VoiceRecorder {
|
|||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func startRecording() throws -> URL {
|
func startRecording() throws -> URL {
|
||||||
|
try queue.sync {
|
||||||
if recorder?.isRecording == true {
|
if recorder?.isRecording == true {
|
||||||
throw RecorderError.recordingInProgress
|
throw RecorderError.recordingInProgress
|
||||||
}
|
}
|
||||||
@@ -52,20 +57,11 @@ actor VoiceRecorder {
|
|||||||
guard session.recordPermission == .granted else {
|
guard session.recordPermission == .granted else {
|
||||||
throw RecorderError.microphoneAccessDenied
|
throw RecorderError.microphoneAccessDenied
|
||||||
}
|
}
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord,
|
.playAndRecord,
|
||||||
mode: .default,
|
mode: .default,
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
)
|
)
|
||||||
#endif
|
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -79,50 +75,68 @@ actor VoiceRecorder {
|
|||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 16_000,
|
AVSampleRateKey: 16_000,
|
||||||
AVNumberOfChannelsKey: 1,
|
AVNumberOfChannelsKey: 1,
|
||||||
AVEncoderBitRateKey: 16_000
|
AVEncoderBitRateKey: 20_000
|
||||||
]
|
]
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.delegate = self
|
||||||
audioRecorder.isMeteringEnabled = true
|
audioRecorder.isMeteringEnabled = true
|
||||||
audioRecorder.prepareToRecord()
|
audioRecorder.prepareToRecord()
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
audioRecorder.record()
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = outputURL
|
||||||
|
stopWorkItem?.cancel()
|
||||||
|
stopWorkItem = nil
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() async -> URL? {
|
|
||||||
guard let recorder, recorder.isRecording else {
|
|
||||||
return currentURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessionURL = currentURL
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||||
|
completion(self?.currentURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
|
self.cleanupSession()
|
||||||
// A new session may have started during the sleep — don't touch its state
|
let url = self.currentURL
|
||||||
if self.recorder === recorder {
|
|
||||||
cleanupSession()
|
|
||||||
self.recorder = nil
|
self.recorder = nil
|
||||||
currentURL = nil
|
self.currentURL = url
|
||||||
|
completion(url)
|
||||||
|
}
|
||||||
|
self.stopWorkItem = item
|
||||||
|
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRecording() {
|
func cancelRecording() {
|
||||||
if let recorder, recorder.isRecording {
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopWorkItem?.cancel()
|
||||||
|
self.stopWorkItem = nil
|
||||||
|
if let recorder = self.recorder, recorder.isRecording {
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
}
|
}
|
||||||
cleanupSession()
|
self.cleanupSession()
|
||||||
if let currentURL {
|
if let url = self.currentURL {
|
||||||
try? FileManager.default.removeItem(at: currentURL)
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metering
|
||||||
|
|
||||||
|
func currentAveragePower() -> Float {
|
||||||
|
queue.sync {
|
||||||
|
recorder?.updateMeters()
|
||||||
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||||
}
|
}
|
||||||
recorder = nil
|
|
||||||
currentURL = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -81,7 +81,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - Three-Layer Identity Model
|
// MARK: - Three-Layer Identity Model
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -151,69 +150,39 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
// Debouncing for keychain saves
|
||||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
private var saveTimer: Timer?
|
||||||
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
||||||
// 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.)
|
|
||||||
private var pendingSave = false
|
private var pendingSave = false
|
||||||
|
|
||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
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) {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// Retrieve (or, only on genuine first run, generate) the cache
|
// Generate or retrieve encryption key from keychain
|
||||||
// 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.
|
|
||||||
let loadedKey: SymmetricKey
|
let loadedKey: SymmetricKey
|
||||||
let keyIsEphemeral: Bool
|
|
||||||
|
|
||||||
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
// Try to load from keychain
|
||||||
case .success(let keyData):
|
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||||
loadedKey = SymmetricKey(data: keyData)
|
loadedKey = SymmetricKey(data: keyData)
|
||||||
keyIsEphemeral = false
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||||
|
}
|
||||||
case .itemNotFound:
|
// Generate new key if needed
|
||||||
// Genuine first run: generate and persist a new key.
|
else {
|
||||||
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)
|
|
||||||
loadedKey = SymmetricKey(size: .bits256)
|
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.encryptionKey = loadedKey
|
||||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
|
||||||
|
|
||||||
// Only read the persisted cache when we hold the real key; with an
|
// Load identity cache on init
|
||||||
// ephemeral key the decrypt would fail and discard the real cache.
|
|
||||||
if !keyIsEphemeral {
|
|
||||||
loadIdentityCache()
|
loadIdentityCache()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
forceSave()
|
forceSave()
|
||||||
@@ -232,37 +201,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||||
} catch {
|
} catch {
|
||||||
cache = IdentityCache()
|
// Log error but continue with empty cache
|
||||||
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
||||||
SecureLogger.warning(
|
|
||||||
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
|
||||||
category: .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() {
|
private func saveIdentityCache() {
|
||||||
|
// Mark that we need to save
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
performSave()
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
|
||||||
/// (barrier) access.
|
|
||||||
private func performSave() {
|
private func performSave() {
|
||||||
guard pendingSave else { return }
|
guard pendingSave else { return }
|
||||||
pendingSave = false
|
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 {
|
do {
|
||||||
let data = try JSONEncoder().encode(cache)
|
let data = try JSONEncoder().encode(cache)
|
||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
@@ -275,13 +235,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
// Force immediate save (for app termination)
|
||||||
// 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.
|
|
||||||
func forceSave() {
|
func forceSave() {
|
||||||
|
saveTimer?.invalidate()
|
||||||
performSave()
|
performSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,15 +331,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
|
||||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||||
|
|
||||||
// Update nickname index
|
// Update nickname index
|
||||||
if let previousClaimedNickname,
|
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||||
previousClaimedNickname != identity.claimedNickname {
|
// Remove old nickname from index if changed
|
||||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||||
|
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,16 +532,4 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
return cache.verifiedFingerprints
|
return cache.verifiedFingerprints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -2,8 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>AppGroupID</key>
|
|
||||||
<string>$(APP_GROUP_ID)</string>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -39,12 +37,12 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</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>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
+135
-994
File diff suppressed because it is too large
Load Diff
@@ -1,69 +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)
|
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
switch self {
|
|
||||||
case .voice(let url), .image(let url):
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-28
@@ -6,40 +6,34 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import class Foundation.DateFormatter
|
import Foundation
|
||||||
|
|
||||||
import struct Foundation.AttributedString
|
|
||||||
import struct Foundation.Data
|
|
||||||
import struct Foundation.Date
|
|
||||||
import struct Foundation.TimeInterval
|
|
||||||
import struct Foundation.UUID
|
|
||||||
|
|
||||||
/// Represents a user-visible message in the BitChat system.
|
/// Represents a user-visible message in the BitChat system.
|
||||||
/// Handles both broadcast messages and private encrypted messages,
|
/// Handles both broadcast messages and private encrypted messages,
|
||||||
/// with support for mentions, replies, and delivery tracking.
|
/// with support for mentions, replies, and delivery tracking.
|
||||||
/// - Note: This is the primary data model for chat messages
|
/// - Note: This is the primary data model for chat messages
|
||||||
public final class BitchatMessage: Codable {
|
final class BitchatMessage: Codable {
|
||||||
public let id: String
|
let id: String
|
||||||
public let sender: String
|
let sender: String
|
||||||
public let content: String
|
let content: String
|
||||||
public let timestamp: Date
|
let timestamp: Date
|
||||||
public let isRelay: Bool
|
let isRelay: Bool
|
||||||
public let originalSender: String?
|
let originalSender: String?
|
||||||
public let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
public let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
public let senderPeerID: PeerID?
|
let senderPeerID: PeerID?
|
||||||
public let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
public var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
// Cached formatted text (not included in Codable)
|
// Cached formatted text (not included in Codable)
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
public func getCachedFormattedText(isDark: Bool, isSelf: Bool, variant: String = "") -> AttributedString? {
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
return _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"]
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") {
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
_cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codable implementation
|
// Codable implementation
|
||||||
@@ -48,7 +42,7 @@ public final class BitchatMessage: Codable {
|
|||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
init(
|
||||||
id: String? = nil,
|
id: String? = nil,
|
||||||
sender: String,
|
sender: String,
|
||||||
content: String,
|
content: String,
|
||||||
@@ -78,7 +72,7 @@ public final class BitchatMessage: Codable {
|
|||||||
// MARK: - Equatable Conformance
|
// MARK: - Equatable Conformance
|
||||||
|
|
||||||
extension BitchatMessage: Equatable {
|
extension BitchatMessage: Equatable {
|
||||||
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||||
return lhs.id == rhs.id &&
|
return lhs.id == rhs.id &&
|
||||||
lhs.sender == rhs.sender &&
|
lhs.sender == rhs.sender &&
|
||||||
lhs.content == rhs.content &&
|
lhs.content == rhs.content &&
|
||||||
@@ -336,15 +330,15 @@ extension BitchatMessage {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public var formattedTimestamp: String {
|
var formattedTimestamp: String {
|
||||||
Self.timestampFormatter.string(from: timestamp)
|
Self.timestampFormatter.string(from: timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == BitchatMessage {
|
extension Array where Element == BitchatMessage {
|
||||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
public func cleanedAndDeduped() -> [Element] {
|
func cleanedAndDeduped() -> [Element] {
|
||||||
let arr = filter { $0.content.trimmed.isEmpty == false }
|
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||||
guard arr.count > 1 else {
|
guard arr.count > 1 else {
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
+16
-25
@@ -6,26 +6,23 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
|
||||||
|
|
||||||
/// The core packet structure for all BitChat protocol messages.
|
/// The core packet structure for all BitChat protocol messages.
|
||||||
/// Encapsulates all data needed for routing through the mesh network,
|
/// Encapsulates all data needed for routing through the mesh network,
|
||||||
/// including TTL for hop limiting and optional encryption.
|
/// including TTL for hop limiting and optional encryption.
|
||||||
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||||
public struct BitchatPacket: Codable {
|
struct BitchatPacket: Codable {
|
||||||
let version: UInt8
|
let version: UInt8
|
||||||
public let type: UInt8
|
let type: UInt8
|
||||||
public let senderID: Data
|
let senderID: Data
|
||||||
public let recipientID: Data?
|
let recipientID: Data?
|
||||||
public let timestamp: UInt64
|
let timestamp: UInt64
|
||||||
public let payload: Data
|
let payload: Data
|
||||||
public var signature: Data?
|
var signature: Data?
|
||||||
public var ttl: UInt8
|
var ttl: UInt8
|
||||||
public var route: [Data]?
|
|
||||||
public var isRSR: Bool
|
|
||||||
|
|
||||||
public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
@@ -34,12 +31,10 @@ public struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = signature
|
self.signature = signature
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = route
|
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
@@ -58,26 +53,24 @@ public struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = nil
|
self.signature = nil
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
self.route = nil
|
|
||||||
self.isRSR = isRSR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data? {
|
var data: Data? {
|
||||||
BinaryProtocol.encode(self)
|
BinaryProtocol.encode(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func toBinaryData(padding: Bool = true) -> Data? {
|
func toBinaryData(padding: Bool = true) -> Data? {
|
||||||
BinaryProtocol.encode(self, padding: padding)
|
BinaryProtocol.encode(self, padding: padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward-compatible helper (defaults to padded encoding)
|
// Backward-compatible helper (defaults to padded encoding)
|
||||||
public func toBinaryData() -> Data? {
|
func toBinaryData() -> Data? {
|
||||||
toBinaryData(padding: true)
|
toBinaryData(padding: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create binary representation for signing (without signature and TTL fields)
|
/// Create binary representation for signing (without signature and TTL fields)
|
||||||
/// TTL is excluded because it changes during packet relay operations
|
/// TTL is excluded because it changes during packet relay operations
|
||||||
public func toBinaryDataForSigning() -> Data? {
|
func toBinaryDataForSigning() -> Data? {
|
||||||
// Create a copy without signature and with fixed TTL for signing
|
// Create a copy without signature and with fixed TTL for signing
|
||||||
// TTL must be excluded because it changes during relay
|
// TTL must be excluded because it changes during relay
|
||||||
let unsignedPacket = BitchatPacket(
|
let unsignedPacket = BitchatPacket(
|
||||||
@@ -88,14 +81,12 @@ public struct BitchatPacket: Codable {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
version: version,
|
version: version
|
||||||
route: route,
|
|
||||||
isRSR: false // RSR flag is mutable and not part of the signature
|
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func from(_ data: Data) -> BitchatPacket? {
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
BinaryProtocol.decode(data)
|
BinaryProtocol.decode(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
|
|||||||
@@ -1,58 +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 {
|
|
||||||
case block
|
|
||||||
case clear
|
|
||||||
case hug
|
|
||||||
case message = "dm"
|
|
||||||
case slap
|
|
||||||
case unblock
|
|
||||||
case who
|
|
||||||
case favorite
|
|
||||||
case unfavorite
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var alias: String { "/" + rawValue }
|
|
||||||
|
|
||||||
var placeholder: String? {
|
|
||||||
switch self {
|
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
|
||||||
case .clear, .who:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .block: String(localized: "content.commands.block")
|
|
||||||
case .clear: String(localized: "content.commands.clear")
|
|
||||||
case .hug: String(localized: "content.commands.hug")
|
|
||||||
case .message: String(localized: "content.commands.message")
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
|
||||||
if isGeoPublic || isGeoDM {
|
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
|
||||||
}
|
|
||||||
return baseCommands
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
|
|
||||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||||
+40
-51
@@ -1,27 +1,15 @@
|
|||||||
//
|
//
|
||||||
// PeerID.swift
|
// PeerID.swift
|
||||||
// BitFoundation
|
// bitchat
|
||||||
//
|
//
|
||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.CharacterSet
|
|
||||||
|
|
||||||
public struct PeerID: Equatable, Hashable, Sendable {
|
struct PeerID: Equatable, Hashable {
|
||||||
enum Constants {
|
enum Prefix: String, CaseIterable {
|
||||||
/// 16
|
|
||||||
static let nostrConvKeyPrefixLength = 16
|
|
||||||
/// 8
|
|
||||||
static let nostrShortKeyDisplayLength = 8
|
|
||||||
/// 64
|
|
||||||
fileprivate static let maxIDLength = 64
|
|
||||||
/// 16
|
|
||||||
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Prefix: String, CaseIterable, Sendable {
|
|
||||||
/// When no prefix is provided
|
/// When no prefix is provided
|
||||||
case empty = ""
|
case empty = ""
|
||||||
/// `"mesh:"`
|
/// `"mesh:"`
|
||||||
@@ -36,32 +24,32 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
|||||||
case geoChat = "nostr:"
|
case geoChat = "nostr:"
|
||||||
}
|
}
|
||||||
|
|
||||||
public let prefix: Prefix
|
let prefix: Prefix
|
||||||
|
|
||||||
/// Returns the actual value without any prefix
|
/// Returns the actual value without any prefix
|
||||||
public let bare: String
|
let bare: String
|
||||||
|
|
||||||
/// Returns the full `id` value by combining `(prefix + bare)`
|
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||||
public var id: String { prefix.rawValue + bare }
|
var id: String { prefix.rawValue + bare }
|
||||||
|
|
||||||
// Private so the callers have to go through a convenience init
|
// Private so the callers have to go through a convenience init
|
||||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
self.bare = String(bare).lowercased()
|
self.bare = String(bare)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Inits
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||||
init(nostr_ pubKey: String) {
|
init(nostr_ pubKey: String) {
|
||||||
self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength))
|
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||||
init(nostr pubKey: String) {
|
init(nostr pubKey: String) {
|
||||||
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength))
|
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||||
@@ -88,17 +76,11 @@ public extension PeerID {
|
|||||||
init(hexData: Data) {
|
init(hexData: Data) {
|
||||||
self.init(str: hexData.hexEncodedString())
|
self.init(str: hexData.hexEncodedString())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience init to "hide" hex-encoding implementation detail
|
|
||||||
init?(hexData: Data?) {
|
|
||||||
guard let hexData else { return nil }
|
|
||||||
self.init(hexData: hexData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noise Public Key Helpers
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
init(publicKey: Data) {
|
init(publicKey: Data) {
|
||||||
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||||
@@ -116,11 +98,11 @@ public extension PeerID {
|
|||||||
// MARK: - Codable
|
// MARK: - Codable
|
||||||
|
|
||||||
extension PeerID: Codable {
|
extension PeerID: Codable {
|
||||||
public init(from decoder: any Decoder) throws {
|
init(from decoder: any Decoder) throws {
|
||||||
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: any Encoder) throws {
|
func encode(to encoder: any Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
try container.encode(id)
|
try container.encode(id)
|
||||||
}
|
}
|
||||||
@@ -128,7 +110,7 @@ extension PeerID: Codable {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
id.isEmpty
|
id.isEmpty
|
||||||
}
|
}
|
||||||
@@ -148,23 +130,14 @@ public extension PeerID {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension PeerID {
|
|
||||||
var routingData: Data? {
|
|
||||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
|
||||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
|
||||||
let short = toShort()
|
|
||||||
return Data(hexString: short.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(routingData: Data) {
|
|
||||||
guard routingData.count == 8 else { return nil }
|
|
||||||
self.init(hexData: routingData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
public extension PeerID {
|
extension PeerID {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
var isValid: Bool {
|
var isValid: Bool {
|
||||||
if prefix != .empty {
|
if prefix != .empty {
|
||||||
@@ -213,16 +186,32 @@ public extension PeerID {
|
|||||||
// MARK: - Comparable
|
// MARK: - Comparable
|
||||||
|
|
||||||
extension PeerID: Comparable {
|
extension PeerID: Comparable {
|
||||||
public static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||||
lhs.id < rhs.id
|
lhs.id < rhs.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CustomStringConvertible
|
// MARK: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
extension PeerID: CustomStringConvertible {
|
extension PeerID: CustomStringConvertible {
|
||||||
/// So it returns the actual `id` like before even inside another String
|
/// So it returns the actual `id` like before even inside another String
|
||||||
public var description: String {
|
var description: String {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
@@ -7,16 +7,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
let receiptID: String
|
let receiptID: String
|
||||||
var readerID: PeerID // Who read it
|
var readerID: String // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = UUID().uuidString
|
self.receiptID = UUID().uuidString
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -25,7 +24,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For binary decoding
|
// For binary decoding
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = receiptID
|
self.receiptID = receiptID
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -49,7 +48,7 @@ struct ReadReceipt: Codable {
|
|||||||
data.appendUUID(receiptID)
|
data.appendUUID(receiptID)
|
||||||
// ReaderID as 8-byte hex string
|
// ReaderID as 8-byte hex string
|
||||||
var readerData = Data()
|
var readerData = Data()
|
||||||
var tempID = readerID.id
|
var tempID = readerID
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -79,8 +78,8 @@ struct ReadReceipt: Codable {
|
|||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = PeerID(hexData: readerIDData)
|
let readerID = readerIDData.hexEncodedString()
|
||||||
guard readerID.isValid else { return nil }
|
guard PeerID(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -4,25 +4,10 @@ import Foundation
|
|||||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
// - 0x04: wanted types — raw MessageType bytes
|
|
||||||
// - 0x05: minimum timestamp (uint64, big-endian) — epoch milliseconds
|
|
||||||
// - 0x06: fragment id filter (utf8)
|
|
||||||
struct RequestSyncPacket {
|
struct RequestSyncPacket {
|
||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
let types: SyncTypeFlags?
|
|
||||||
let sinceTimestamp: UInt64?
|
|
||||||
let fragmentIdFilter: String?
|
|
||||||
|
|
||||||
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 {
|
func encode() -> Data {
|
||||||
var out = Data()
|
var out = Data()
|
||||||
@@ -40,16 +25,6 @@ struct RequestSyncPacket {
|
|||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
// data
|
// data
|
||||||
putTLV(0x03, 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +33,6 @@ struct RequestSyncPacket {
|
|||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
var types: SyncTypeFlags? = nil
|
|
||||||
var sinceTimestamp: UInt64? = nil
|
|
||||||
var fragmentIdFilter: String? = nil
|
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -80,26 +52,12 @@ struct RequestSyncPacket {
|
|||||||
case 0x03:
|
case 0x03:
|
||||||
if v.count > maxAcceptBytes { return nil }
|
if v.count > maxAcceptBytes { return nil }
|
||||||
payload = v
|
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:
|
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
|
||||||
fragmentIdFilter = fid
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
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 }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -166,12 +165,8 @@ final class NoiseCipherState {
|
|||||||
// MARK: - Sliding Window Replay Protection
|
// MARK: - Sliding Window Replay Protection
|
||||||
|
|
||||||
/// Check if nonce is valid for 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 {
|
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
|
||||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
|
||||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
|
||||||
return false // Too old, outside window
|
return false // Too old, outside window
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,13 +317,6 @@ final class NoiseCipherState {
|
|||||||
throw NoiseError.replayDetected
|
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
|
// Split ciphertext and tag
|
||||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
tag = actualCiphertext.suffix(16)
|
tag = actualCiphertext.suffix(16)
|
||||||
@@ -360,19 +348,15 @@ final class NoiseCipherState {
|
|||||||
do {
|
do {
|
||||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
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 {
|
if useExtractedNonce {
|
||||||
|
// Mark nonce as seen after successful decryption
|
||||||
markNonceAsSeen(decryptionNonce)
|
markNonceAsSeen(decryptionNonce)
|
||||||
}
|
}
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
|
||||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
|
// Log authentication failures with nonce info
|
||||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -392,16 +376,6 @@ final class NoiseCipherState {
|
|||||||
replayWindow[i] = 0
|
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
|
// MARK: - Symmetric State
|
||||||
@@ -477,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 output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||||
|
|
||||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
|
||||||
// The chaining key and hash should not be retained after handshake completes
|
|
||||||
clearSensitiveData()
|
|
||||||
|
|
||||||
return (c1, c2)
|
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
|
// HKDF implementation
|
||||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||||
@@ -556,24 +507,16 @@ final class NoiseHandshakeState {
|
|||||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||||
private var currentPattern = 0
|
private var currentPattern = 0
|
||||||
|
|
||||||
// Test support: predetermined ephemeral keys for test vectors
|
|
||||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
|
||||||
private var prologueData: Data
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
pattern: NoisePattern,
|
pattern: NoisePattern,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
prologue: Data = Data(),
|
|
||||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
|
||||||
) {
|
) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.prologueData = prologue
|
|
||||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
|
||||||
|
|
||||||
// Initialize static keys
|
// Initialize static keys
|
||||||
if let localKey = localStaticKey {
|
if let localKey = localStaticKey {
|
||||||
@@ -594,8 +537,8 @@ final class NoiseHandshakeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mixPreMessageKeys() {
|
private func mixPreMessageKeys() {
|
||||||
// Mix prologue
|
// Mix prologue (empty for XX pattern normally)
|
||||||
symmetricState.mixHash(self.prologueData)
|
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||||
// For XX pattern, no pre-message keys
|
// For XX pattern, no pre-message keys
|
||||||
// For IK/NK patterns, we'd mix the responder's static key here
|
// For IK/NK patterns, we'd mix the responder's static key here
|
||||||
switch pattern {
|
switch pattern {
|
||||||
@@ -603,9 +546,8 @@ final class NoiseHandshakeState {
|
|||||||
break // No pre-message keys
|
break // No pre-message keys
|
||||||
case .IK, .NK:
|
case .IK, .NK:
|
||||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||||
|
_ = symmetricState.getHandshakeHash()
|
||||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||||
} else if role == .responder, let localStatic = localStaticPublic {
|
|
||||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,13 +563,8 @@ final class NoiseHandshakeState {
|
|||||||
for pattern in patterns {
|
for pattern in patterns {
|
||||||
switch pattern {
|
switch pattern {
|
||||||
case .e:
|
case .e:
|
||||||
// Generate ephemeral key (or use predetermined key for tests)
|
// Generate ephemeral key
|
||||||
if let predetermined = predeterminedEphemeralKey {
|
|
||||||
localEphemeralPrivate = predetermined
|
|
||||||
predeterminedEphemeralKey = nil
|
|
||||||
} else {
|
|
||||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||||
}
|
|
||||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||||
@@ -660,20 +597,14 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
} else {
|
} else {
|
||||||
guard let localStatic = localStaticPrivate,
|
guard let localStatic = localStaticPrivate,
|
||||||
let remoteEphemeral = remoteEphemeralPublic else {
|
let remoteEphemeral = remoteEphemeralPublic else {
|
||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .se:
|
case .se:
|
||||||
@@ -684,20 +615,14 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
} else {
|
} else {
|
||||||
guard let localEphemeral = localEphemeralPrivate,
|
guard let localEphemeral = localEphemeralPrivate,
|
||||||
let remoteStatic = remoteStaticPublic else {
|
let remoteStatic = remoteStaticPublic else {
|
||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .ss:
|
case .ss:
|
||||||
@@ -786,10 +711,7 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
|
|
||||||
case .es:
|
case .es:
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
@@ -843,10 +765,7 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
|
|
||||||
case .e, .s:
|
case .e, .s:
|
||||||
break
|
break
|
||||||
@@ -857,20 +776,16 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
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 {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
let (c1, c2) = symmetricState.split()
|
||||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
@@ -880,20 +795,6 @@ final class NoiseHandshakeState {
|
|||||||
func getHandshakeHash() -> Data {
|
func getHandshakeHash() -> Data {
|
||||||
return symmetricState.getHandshakeHash()
|
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
|
// MARK: - Pattern Extensions
|
||||||
@@ -945,44 +846,19 @@ enum NoiseError: Error {
|
|||||||
case nonceExceeded
|
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
|
// MARK: - Key Validation
|
||||||
|
|
||||||
extension NoiseHandshakeState {
|
extension NoiseHandshakeState {
|
||||||
/// Validate a Curve25519 public key
|
/// Validate a Curve25519 public key
|
||||||
/// Checks for weak/invalid keys that could compromise security
|
/// 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 {
|
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
// Check key length
|
// Check key length
|
||||||
guard keyData.count == 32 else {
|
guard keyData.count == 32 else {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
// Check for all-zero key (point at infinity)
|
||||||
if constantTimeIsZero(keyData) {
|
if keyData.allSatisfy({ $0 == 0 }) {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,16 +883,8 @@ extension NoiseHandshakeState {
|
|||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||||
]
|
]
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check against known bad points
|
// Check against known bad points
|
||||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
if lowOrderPoints.contains(keyData) {
|
||||||
var foundBadPoint = false
|
|
||||||
for badPoint in lowOrderPoints {
|
|
||||||
if constantTimeCompare(keyData, badPoint) {
|
|
||||||
foundBadPoint = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundBadPoint {
|
|
||||||
SecureLogger.warning("Low-order point detected", category: .security)
|
SecureLogger.warning("Low-order point detected", category: .security)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
@@ -103,8 +102,8 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
@@ -112,7 +111,7 @@ class NoiseSession {
|
|||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
@@ -129,8 +128,8 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
// Get transport ciphers
|
||||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
@@ -138,7 +137,7 @@ class NoiseSession {
|
|||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = hash
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|||||||
@@ -9,13 +9,11 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
@@ -25,27 +23,7 @@ final class NoiseSessionManager {
|
|||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
self.localStaticKey = localStaticKey
|
self.localStaticKey = localStaticKey
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.sessionFactory = { peerID, role in
|
|
||||||
SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
init(
|
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
|
||||||
keychain: KeychainManagerProtocol,
|
|
||||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
|
||||||
) {
|
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
self.sessionFactory = sessionFactory
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
@@ -88,7 +66,12 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = sessionFactory(peerID, .initiator)
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -134,7 +117,12 @@ final class NoiseSessionManager {
|
|||||||
// Get or create session
|
// Get or create session
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
if shouldCreateNew {
|
if shouldCreateNew {
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
final class SecureNoiseSession: NoiseSession {
|
||||||
private(set) var messageCount: UInt64 = 0
|
private(set) var messageCount: UInt64 = 0
|
||||||
private var sessionStartTime = Date()
|
private let sessionStartTime = Date()
|
||||||
private(set) var lastActivityTime = Date()
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
@@ -77,9 +77,5 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
func setMessageCountForTesting(_ count: UInt64) {
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
messageCount = count
|
messageCount = count
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionStartTimeForTesting(_ date: Date) {
|
|
||||||
sessionStartTime = date
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum Base64URLCoding {
|
|
||||||
static func encode(_ data: Data) -> String {
|
|
||||||
data.base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ string: String) -> Data? {
|
|
||||||
var base64 = string
|
|
||||||
let padding = (4 - (base64.count % 4)) % 4
|
|
||||||
if padding > 0 {
|
|
||||||
base64 += String(repeating: "=", count: padding)
|
|
||||||
}
|
|
||||||
base64 = base64
|
|
||||||
.replacingOccurrences(of: "-", with: "+")
|
|
||||||
.replacingOccurrences(of: "_", with: "/")
|
|
||||||
return Data(base64Encoded: base64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +1,29 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
import Tor
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
/// Posted after the geo relay directory successfully refreshes its entries.
|
|
||||||
static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
struct GeoRelayDirectoryDependencies {
|
|
||||||
var userDefaults: UserDefaults
|
|
||||||
var notificationCenter: NotificationCenter
|
|
||||||
var now: () -> Date
|
|
||||||
var remoteURL: URL
|
|
||||||
var fetchInterval: TimeInterval
|
|
||||||
var refreshCheckInterval: TimeInterval
|
|
||||||
var retryInitialSeconds: TimeInterval
|
|
||||||
var retryMaxSeconds: TimeInterval
|
|
||||||
var awaitTorReady: @Sendable () async -> Bool
|
|
||||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
|
||||||
var readData: (URL) -> Data?
|
|
||||||
var writeData: (Data, URL) throws -> Void
|
|
||||||
var cacheURL: () -> URL?
|
|
||||||
var bundledCSVURLs: () -> [URL]
|
|
||||||
var currentDirectoryPath: () -> String?
|
|
||||||
var retrySleep: (TimeInterval) async -> Void
|
|
||||||
var activeNotificationName: Notification.Name?
|
|
||||||
var autoStart: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension GeoRelayDirectoryDependencies {
|
|
||||||
@MainActor
|
|
||||||
static func live() -> Self {
|
|
||||||
#if os(iOS)
|
|
||||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
|
||||||
#elseif os(macOS)
|
|
||||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
|
||||||
#else
|
|
||||||
let activeNotificationName: Notification.Name? = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return Self(
|
|
||||||
userDefaults: .standard,
|
|
||||||
notificationCenter: .default,
|
|
||||||
now: Date.init,
|
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
|
||||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
|
||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
|
||||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
|
||||||
makeFetchData: {
|
|
||||||
let session = TorURLSession.shared.session
|
|
||||||
return { request in
|
|
||||||
let (data, _) = try await session.data(for: request)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readData: { try? Data(contentsOf: $0) },
|
|
||||||
writeData: { data, url in
|
|
||||||
try data.write(to: url, options: .atomic)
|
|
||||||
},
|
|
||||||
cacheURL: {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir.appendingPathComponent("georelays_cache.csv")
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bundledCSVURLs: {
|
|
||||||
[
|
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
|
||||||
].compactMap { $0 }
|
|
||||||
},
|
|
||||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
|
||||||
retrySleep: { delay in
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
},
|
|
||||||
activeNotificationName: activeNotificationName,
|
|
||||||
autoStart: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectory {
|
final class GeoRelayDirectory {
|
||||||
private final class CleanupState {
|
struct Entry: Hashable {
|
||||||
let notificationCenter: NotificationCenter
|
|
||||||
var observers: [NSObjectProtocol] = []
|
|
||||||
var refreshTimer: Timer?
|
|
||||||
var retryTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(notificationCenter: NotificationCenter) {
|
|
||||||
self.notificationCenter = notificationCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
observers.forEach { notificationCenter.removeObserver($0) }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
retryTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Entry: Hashable, Sendable {
|
|
||||||
let host: String
|
let host: String
|
||||||
let lat: Double
|
let lat: Double
|
||||||
let lon: Double
|
let lon: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum DetachedFetchOutcome: Sendable {
|
|
||||||
case success(entries: [Entry], csv: String)
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let dependencies: GeoRelayDirectoryDependencies
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let cleanupState: CleanupState
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||||
|
|
||||||
private var retryAttempt: Int = 0
|
|
||||||
private var isFetching: Bool = false
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.dependencies = .live()
|
// Load cached or bundled data synchronously
|
||||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
self.entries = self.loadLocalEntries()
|
||||||
entries = loadLocalEntries()
|
// Fire-and-forget remote refresh if stale
|
||||||
if dependencies.autoStart {
|
|
||||||
registerObservers()
|
|
||||||
startRefreshTimer()
|
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
|
||||||
entries = loadLocalEntries()
|
|
||||||
if dependencies.autoStart {
|
|
||||||
registerObservers()
|
|
||||||
startRefreshTimer()
|
|
||||||
prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
@@ -170,162 +32,76 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
/// Ties break by host so every device with the same directory picks the
|
|
||||||
/// same relay set — publishers and subscribers must agree on relays.
|
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty, count > 0 else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
|
|
||||||
|
if entries.count <= count {
|
||||||
return entries
|
return entries
|
||||||
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
|
.sorted { a, b in
|
||||||
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
.prefix(count)
|
}
|
||||||
.map { "wss://\($0.entry.host)" }
|
.map { "wss://\($0.host)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var best: [(entry: Entry, distance: Double)] = []
|
||||||
|
best.reserveCapacity(count)
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||||
|
if best.count < count {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
best.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best.map { "wss://\($0.entry.host)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded() {
|
||||||
guard !isFetching else { return }
|
let now = Date()
|
||||||
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
let now = dependencies.now()
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
|
||||||
|
|
||||||
if !force {
|
|
||||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
|
||||||
} else if last != .distantPast,
|
|
||||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
guard !isFetching else { return }
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
isFetching = true
|
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||||
|
Task.detached {
|
||||||
let request = URLRequest(
|
let ready = await TorManager.shared.awaitReady()
|
||||||
url: dependencies.remoteURL,
|
if !ready {
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||||
timeoutInterval: 15
|
return
|
||||||
)
|
|
||||||
let awaitTorReady = dependencies.awaitTorReady
|
|
||||||
let fetchData = dependencies.makeFetchData()
|
|
||||||
|
|
||||||
Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
let outcome = await Self.fetchRemoteOutcome(
|
|
||||||
request: request,
|
|
||||||
awaitTorReady: awaitTorReady,
|
|
||||||
fetchData: fetchData
|
|
||||||
)
|
|
||||||
|
|
||||||
switch outcome {
|
|
||||||
case .success(let parsed, let csv):
|
|
||||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
|
||||||
case .torNotReady:
|
|
||||||
self.handleFetchFailure(.torNotReady)
|
|
||||||
case .invalidData:
|
|
||||||
self.handleFetchFailure(.invalidData)
|
|
||||||
case .network(let description):
|
|
||||||
self.handleFetchFailure(.network(description))
|
|
||||||
}
|
}
|
||||||
}
|
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||||
}
|
guard let self = self else { return }
|
||||||
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
nonisolated private static func fetchRemoteOutcome(
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
request: URLRequest,
|
if !parsed.isEmpty {
|
||||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
Task { @MainActor in
|
||||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
self.entries = parsed
|
||||||
) async -> DetachedFetchOutcome {
|
self.persistCache(text)
|
||||||
await Task.detached(priority: .utility) {
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
let ready = await awaitTorReady()
|
|
||||||
guard ready else { return .torNotReady }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let data = try await fetchData(request)
|
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
|
||||||
return .invalidData
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed = Self.parseCSV(text)
|
|
||||||
guard !parsed.isEmpty else {
|
|
||||||
return .invalidData
|
|
||||||
}
|
|
||||||
|
|
||||||
return .success(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
return .network(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum FetchFailure {
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
|
||||||
entries = parsed
|
|
||||||
persistCache(csv)
|
|
||||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
isFetching = false
|
|
||||||
retryAttempt = 0
|
|
||||||
cancelRetry()
|
|
||||||
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
|
|
||||||
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
@MainActor
|
|
||||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
|
||||||
switch reason {
|
|
||||||
case .torNotReady:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
|
||||||
case .invalidData:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
|
||||||
case .network(let errorDescription):
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
|
||||||
}
|
|
||||||
isFetching = false
|
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func scheduleRetry() {
|
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
|
||||||
let base = dependencies.retryInitialSeconds
|
|
||||||
let maxDelay = dependencies.retryMaxSeconds
|
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
|
||||||
let calculated = base * multiplier
|
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
cleanupState.retryTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.dependencies.retrySleep(delay)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await MainActor.run {
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func cancelRetry() {
|
|
||||||
cleanupState.retryTask?.cancel()
|
|
||||||
cleanupState.retryTask = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = dependencies.cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
guard let data = text.data(using: .utf8) else { return }
|
|
||||||
do {
|
do {
|
||||||
try dependencies.writeData(data, url)
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
@@ -334,31 +110,30 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = dependencies.cacheURL(),
|
if let cache = self.cacheURL(),
|
||||||
let data = dependencies.readData(cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
let bundleCandidates = [
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = dependencies.readData(url),
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = dependencies.currentDirectoryPath(),
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -366,68 +141,34 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if line.isEmpty { continue }
|
||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
var host = parts[0]
|
||||||
|
host = host.replacingOccurrences(of: "https://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "http://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||||
|
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
return Array(result)
|
return Array(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
private func cacheURL() -> URL? {
|
||||||
private func registerObservers() {
|
do {
|
||||||
let center = dependencies.notificationCenter
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
let torReady = center.addObserver(
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
forName: .TorDidBecomeReady,
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
object: nil,
|
} catch { return nil }
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanupState.observers.append(torReady)
|
|
||||||
|
|
||||||
if let activeNotificationName = dependencies.activeNotificationName {
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: activeNotificationName,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupState.observers.append(didBecomeActive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
|
||||||
cleanupState.refreshTimer?.invalidate()
|
|
||||||
let interval = dependencies.refreshCheckInterval
|
|
||||||
guard interval > 0 else { return }
|
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupState.refreshTimer = timer
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugRetryAttempt: Int { retryAttempt }
|
|
||||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
|
||||||
var debugObserverCount: Int { cleanupState.observers.count }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Distance
|
// MARK: - Distance
|
||||||
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
|
func load(key: String, service: String) -> Data?
|
||||||
|
func delete(key: String, service: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper: KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -15,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -32,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -55,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -63,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -76,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -97,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerID(publicKey: maybeData)
|
return PeerID(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -13,9 +12,9 @@ final class NostrIdentityBridge {
|
|||||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||||
private let cacheLock = NSLock()
|
private let cacheLock = NSLock()
|
||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,13 +81,6 @@ final class NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache = nil
|
||||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
|
||||||
// actual secp256k1 private keys; if left cached, post-panic geohash
|
|
||||||
// messages would still be signed with pre-panic keys (linkable across the
|
|
||||||
// wipe) until the app is force-quit.
|
|
||||||
cacheLock.lock()
|
|
||||||
derivedIdentityCache.removeAll()
|
|
||||||
cacheLock.unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -39,23 +38,22 @@ struct NostrProtocol {
|
|||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
// 2. Create ephemeral key for this message
|
||||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
||||||
// so the recipient can authenticate who sent the message; signing with
|
// Created ephemeral key for seal
|
||||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
|
||||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
// 3. Seal the rumor (encrypt to recipient)
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createSeal(
|
||||||
rumor: rumor,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey
|
senderKey: ephemeralKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
|
||||||
// its own ephemeral key internally).
|
|
||||||
let giftWrap = try createGiftWrap(
|
let giftWrap = try createGiftWrap(
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: ephemeralKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
// Created gift wrap
|
||||||
@@ -85,15 +83,7 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
// 2. Open the seal
|
||||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
|
||||||
// who knows the recipient's npub. Verify the seal's own signature.
|
|
||||||
guard seal.isValidSignature() else {
|
|
||||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Open the seal
|
|
||||||
let rumor: NostrEvent
|
let rumor: NostrEvent
|
||||||
do {
|
do {
|
||||||
rumor = try openSeal(
|
rumor = try openSeal(
|
||||||
@@ -106,62 +96,9 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. The sender claimed inside the rumor must match the key that actually
|
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||||
// signed the seal, otherwise the sender field is unauthenticated and
|
|
||||||
// spoofable.
|
|
||||||
guard seal.pubkey == rumor.pubkey else {
|
|
||||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the seal signer's pubkey as the authenticated sender.
|
|
||||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
|
||||||
content: String,
|
|
||||||
recipientPubkey: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let rumor = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .dm,
|
|
||||||
tags: [],
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
var seal = try createSeal(
|
|
||||||
rumor: rumor,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
senderKey: senderIdentity.schnorrSigningKey()
|
|
||||||
)
|
|
||||||
seal.sig = String(repeating: "0", count: 128)
|
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
|
||||||
content: String,
|
|
||||||
recipientPubkey: String,
|
|
||||||
rumorIdentity: NostrIdentity,
|
|
||||||
sealSignerIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let rumor = NostrEvent(
|
|
||||||
pubkey: rumorIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .dm,
|
|
||||||
tags: [],
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let seal = try createSeal(
|
|
||||||
rumor: rumor,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
|
||||||
)
|
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
static func createEphemeralGeohashEvent(
|
static func createEphemeralGeohashEvent(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -171,7 +108,7 @@ struct NostrProtocol {
|
|||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if teleported {
|
if teleported {
|
||||||
@@ -188,24 +125,6 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
|
||||||
/// Must contain empty content and NO nickname tag
|
|
||||||
static func createGeohashPresenceEvent(
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let tags = [["g", geohash]]
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: tags,
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -214,7 +133,7 @@ struct NostrProtocol {
|
|||||||
nickname: String? = nil
|
nickname: String? = nil
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
@@ -257,7 +176,8 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipientPubkey: String
|
recipientPubkey: String,
|
||||||
|
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let sealJSON = try seal.jsonString()
|
let sealJSON = try seal.jsonString()
|
||||||
@@ -364,7 +284,7 @@ struct NostrProtocol {
|
|||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + Base64URLCoding.encode(combined)
|
return "v2:" + base64URLEncode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -375,7 +295,7 @@ struct NostrProtocol {
|
|||||||
// Expect NIP-44 v2 format
|
// Expect NIP-44 v2 format
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = base64URLDecode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
@@ -590,26 +510,6 @@ struct NostrEvent: Codable {
|
|||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
|
||||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
|
||||||
func isValidSignature() -> Bool {
|
|
||||||
guard let sig = sig,
|
|
||||||
let sigData = Data(hexString: sig),
|
|
||||||
let pubData = Data(hexString: pubkey),
|
|
||||||
sigData.count == 64,
|
|
||||||
pubData.count == 32,
|
|
||||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
|
||||||
let (expectedId, eventHash) = try? calculateEventId(),
|
|
||||||
expectedId == id
|
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageBytes = [UInt8](eventHash)
|
|
||||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
|
||||||
return xonly.isValid(signature, for: &messageBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func calculateEventId() throws -> (String, Data) {
|
private func calculateEventId() throws -> (String, Data) {
|
||||||
let serialized = [
|
let serialized = [
|
||||||
0,
|
0,
|
||||||
@@ -641,9 +541,24 @@ enum NostrError: Error {
|
|||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
|
static func base64URLEncode(_ data: Data) -> String {
|
||||||
|
return data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func base64URLDecode(_ s: String) -> Data? {
|
||||||
|
var str = s
|
||||||
|
let pad = (4 - (str.count % 4)) % 4
|
||||||
|
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||||
|
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
|
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum NostrRelayURL {
|
|
||||||
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
|
||||||
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !value.isEmpty else { return nil }
|
|
||||||
|
|
||||||
if !value.contains("://"), let defaultScheme {
|
|
||||||
value = "\(defaultScheme)://\(value)"
|
|
||||||
}
|
|
||||||
|
|
||||||
guard var components = URLComponents(string: value),
|
|
||||||
let rawScheme = components.scheme?.lowercased(),
|
|
||||||
let rawHost = components.host?.lowercased(),
|
|
||||||
!rawHost.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rawScheme {
|
|
||||||
case "wss", "https":
|
|
||||||
components.scheme = "wss"
|
|
||||||
if components.port == 443 {
|
|
||||||
components.port = nil
|
|
||||||
}
|
|
||||||
case "ws", "http":
|
|
||||||
components.scheme = "ws"
|
|
||||||
if components.port == 80 {
|
|
||||||
components.port = nil
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
components.host = rawHost
|
|
||||||
if components.path == "/" {
|
|
||||||
components.path = ""
|
|
||||||
}
|
|
||||||
components.fragment = nil
|
|
||||||
|
|
||||||
return components.string
|
|
||||||
}
|
|
||||||
|
|
||||||
static func directoryAddress(_ rawValue: String) -> String? {
|
|
||||||
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
|
||||||
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
|
||||||
normalized.removeFirst(prefix.count)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,27 +5,16 @@ import CryptoKit
|
|||||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
/// as per XChaCha20 construction.
|
/// as per XChaCha20 construction.
|
||||||
enum XChaCha20Poly1305Compat {
|
enum XChaCha20Poly1305Compat {
|
||||||
|
|
||||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
|
||||||
enum Error: Swift.Error {
|
|
||||||
case invalidKeyLength(expected: Int, got: Int)
|
|
||||||
case invalidNonceLength(expected: Int, got: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SealBox {
|
struct SealBox {
|
||||||
let ciphertext: Data
|
let ciphertext: Data
|
||||||
let tag: Data
|
let tag: Data
|
||||||
}
|
}
|
||||||
|
|
||||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
@@ -34,14 +23,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||||
@@ -58,14 +43,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32)
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce16.count == 16)
|
||||||
}
|
|
||||||
guard nonce16.count == 16 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants "expand 32-byte k"
|
// Constants "expand 32-byte k"
|
||||||
var state: [UInt32] = [
|
var state: [UInt32] = [
|
||||||
|
|||||||
+46
-13
@@ -5,15 +5,48 @@
|
|||||||
// Binary encoding utilities for efficient protocol messages
|
// Binary encoding utilities for efficient protocol messages
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func hexEncodedString() -> String {
|
||||||
|
if self.isEmpty {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hex() -> String {
|
||||||
|
let digest = SHA256.hash(data: self)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(hexString: String) {
|
||||||
|
let len = hexString.count / 2
|
||||||
|
var data = Data(capacity: len)
|
||||||
|
var index = hexString.startIndex
|
||||||
|
|
||||||
|
for _ in 0..<len {
|
||||||
|
let nextIndex = hexString.index(index, offsetBy: 2)
|
||||||
|
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data.append(byte)
|
||||||
|
index = nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
self = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Binary Encoding Utilities
|
// MARK: - Binary Encoding Utilities
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
// MARK: Writing
|
// MARK: Writing
|
||||||
|
|
||||||
@inlinable public mutating func appendUInt8(_ value: UInt8) {
|
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||||
self.append(value)
|
self.append(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +68,7 @@ extension Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendString(_ string: String, maxLength: Int = 255) {
|
mutating func appendString(_ string: String, maxLength: Int = 255) {
|
||||||
guard let data = string.data(using: .utf8) else { return }
|
guard let data = string.data(using: .utf8) else { return }
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
@@ -48,7 +81,7 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -60,12 +93,12 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendDate(_ date: Date) {
|
mutating func appendDate(_ date: Date) {
|
||||||
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
||||||
self.appendUInt64(timestamp)
|
self.appendUInt64(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendUUID(_ uuid: String) {
|
mutating func appendUUID(_ uuid: String) {
|
||||||
// Convert UUID string to 16 bytes
|
// Convert UUID string to 16 bytes
|
||||||
var uuidData = Data(count: 16)
|
var uuidData = Data(count: 16)
|
||||||
|
|
||||||
@@ -86,7 +119,7 @@ extension Data {
|
|||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
|
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
|
||||||
guard offset >= 0 && offset < self.count else { return nil }
|
guard offset >= 0 && offset < self.count else { return nil }
|
||||||
let value = self[offset]
|
let value = self[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
@@ -120,7 +153,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -139,7 +172,7 @@ extension Data {
|
|||||||
return String(data: stringData, encoding: .utf8)
|
return String(data: stringData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -158,12 +191,12 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readDate(at offset: inout Int) -> Date? {
|
func readDate(at offset: inout Int) -> Date? {
|
||||||
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
||||||
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readUUID(at offset: inout Int) -> String? {
|
func readUUID(at offset: inout Int) -> String? {
|
||||||
guard offset + 16 <= self.count else { return nil }
|
guard offset + 16 <= self.count else { return nil }
|
||||||
|
|
||||||
let uuidData = self[offset..<offset + 16]
|
let uuidData = self[offset..<offset + 16]
|
||||||
@@ -184,7 +217,7 @@ extension Data {
|
|||||||
return result.uppercased()
|
return result.uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
||||||
guard offset + count <= self.count else { return nil }
|
guard offset + count <= self.count else { return nil }
|
||||||
|
|
||||||
let data = self[offset..<offset + count]
|
let data = self[offset..<offset + count]
|
||||||
+34
-69
@@ -88,31 +88,40 @@
|
|||||||
/// - Platform-optimized byte swapping
|
/// - Platform-optimized byte swapping
|
||||||
///
|
///
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import class Foundation.NSData
|
import BitLogger
|
||||||
private import BitLogger
|
|
||||||
|
extension Data {
|
||||||
|
func trimmingNullBytes() -> Data {
|
||||||
|
// Find the first null byte
|
||||||
|
if let nullIndex = self.firstIndex(of: 0) {
|
||||||
|
return self.prefix(nullIndex)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implements binary encoding and decoding for BitChat protocol messages.
|
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||||
/// Provides static methods for converting between BitchatPacket objects and
|
/// Provides static methods for converting between BitchatPacket objects and
|
||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
public struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
public static let v1HeaderSize = 14
|
static let v1HeaderSize = 14
|
||||||
static let v2HeaderSize = 16
|
static let v2HeaderSize = 16
|
||||||
public static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
public static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
public static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
// Field offsets within packet header
|
// Field offsets within packet header
|
||||||
public struct Offsets {
|
struct Offsets {
|
||||||
static let version = 0
|
static let version = 0
|
||||||
static let type = 1
|
static let type = 1
|
||||||
static let ttl = 2
|
static let ttl = 2
|
||||||
static let timestamp = 3
|
static let timestamp = 3
|
||||||
public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func headerSize(for version: UInt8) -> Int? {
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
switch version {
|
switch version {
|
||||||
case 1: return v1HeaderSize
|
case 1: return v1HeaderSize
|
||||||
case 2: return v2HeaderSize
|
case 2: return v2HeaderSize
|
||||||
@@ -124,12 +133,10 @@ public struct BinaryProtocol {
|
|||||||
return version == 2 ? 4 : 2
|
return version == 2 ? 4 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Flags {
|
struct Flags {
|
||||||
public static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
public static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
public static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
public static let hasRoute: UInt8 = 0x08
|
|
||||||
static let isRSR: UInt8 = 0x10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
@@ -153,30 +160,14 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lengthFieldBytes = lengthFieldSize(for: version)
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
|
|
||||||
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
|
|
||||||
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
|
|
||||||
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
|
||||||
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
|
||||||
if hop.count == senderIDSize { return hop }
|
|
||||||
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
|
|
||||||
var padded = hop
|
|
||||||
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
|
|
||||||
return padded
|
|
||||||
}
|
|
||||||
guard sanitizedRoute.count <= 255 else { return nil }
|
|
||||||
|
|
||||||
let hasRoute = !sanitizedRoute.isEmpty
|
|
||||||
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
|
||||||
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// payloadLength in header is payload-only (does NOT include route bytes)
|
|
||||||
let payloadDataSize = payload.count + originalSizeFieldBytes
|
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||||
|
|
||||||
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
|
|
||||||
guard let headerSize = headerSize(for: version) else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
let estimatedPayload = payloadDataSize
|
let estimatedPayload = payloadDataSize
|
||||||
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
var data = Data()
|
var data = Data()
|
||||||
@@ -194,9 +185,6 @@ public struct BinaryProtocol {
|
|||||||
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
if packet.signature != nil { flags |= Flags.hasSignature }
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
if isCompressed { flags |= Flags.isCompressed }
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
// HAS_ROUTE is only valid for v2+ packets
|
|
||||||
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
|
|
||||||
if packet.isRSR { flags |= Flags.isRSR }
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
@@ -224,13 +212,6 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
data.append(UInt8(sanitizedRoute.count))
|
|
||||||
for hop in sanitizedRoute {
|
|
||||||
data.append(hop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
let value = UInt32(originalSize)
|
let value = UInt32(originalSize)
|
||||||
@@ -257,7 +238,7 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
public static func decode(_ data: Data) -> BitchatPacket? {
|
static func decode(_ data: Data) -> BitchatPacket? {
|
||||||
// Try decode as-is first (robust when padding wasn't applied)
|
// Try decode as-is first (robust when padding wasn't applied)
|
||||||
if let pkt = decodeCore(data) { return pkt }
|
if let pkt = decodeCore(data) { return pkt }
|
||||||
// If that fails, try after removing padding
|
// If that fails, try after removing padding
|
||||||
@@ -320,9 +301,6 @@ public struct BinaryProtocol {
|
|||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
|
||||||
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
|
|
||||||
let isRSR = (flags & Flags.isRSR) != 0
|
|
||||||
|
|
||||||
let payloadLength: Int
|
let payloadLength: Int
|
||||||
if version == 2 {
|
if version == 2 {
|
||||||
@@ -334,7 +312,6 @@ public struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard payloadLength >= 0 else { return nil }
|
guard payloadLength >= 0 else { return nil }
|
||||||
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
|
||||||
|
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
@@ -344,21 +321,6 @@ public struct BinaryProtocol {
|
|||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
|
|
||||||
var route: [Data]? = nil
|
|
||||||
if hasRoute {
|
|
||||||
guard let routeCount = read8() else { return nil }
|
|
||||||
if routeCount > 0 {
|
|
||||||
var hops: [Data] = []
|
|
||||||
for _ in 0..<Int(routeCount) {
|
|
||||||
guard let hop = readData(senderIDSize) else { return nil }
|
|
||||||
hops.append(hop)
|
|
||||||
}
|
|
||||||
route = hops
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
guard payloadLength >= lengthFieldBytes else { return nil }
|
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||||
@@ -370,10 +332,15 @@ public struct BinaryProtocol {
|
|||||||
guard let rawSize = read16() else { return nil }
|
guard let rawSize = read16() else { return nil }
|
||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
|
||||||
|
// Validate compression ratio to prevent zip bomb attacks
|
||||||
|
// Primary protection: originalSize capped at 1MB (line 336)
|
||||||
|
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||||
|
guard compressedSize > 0 else { return nil }
|
||||||
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
guard compressionRatio <= 50_000.0 else {
|
guard compressionRatio <= 50_000.0 else {
|
||||||
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
@@ -404,9 +371,7 @@ public struct BinaryProtocol {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
version: version,
|
version: version
|
||||||
route: route,
|
|
||||||
isRSR: isRSR
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
|||||||
@@ -60,7 +60,40 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
|
||||||
|
// MARK: - Message Types
|
||||||
|
|
||||||
|
/// Simplified BitChat protocol message types.
|
||||||
|
/// Reduced from 24 types to just 6 essential ones.
|
||||||
|
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
||||||
|
enum MessageType: UInt8 {
|
||||||
|
// Public messages (unencrypted)
|
||||||
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
|
case message = 0x02 // Public chat message
|
||||||
|
case leave = 0x03 // "I'm leaving"
|
||||||
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
|
// Noise encryption
|
||||||
|
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||||
|
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||||
|
|
||||||
|
// Fragmentation (simplified)
|
||||||
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .announce: return "announce"
|
||||||
|
case .message: return "message"
|
||||||
|
case .leave: return "leave"
|
||||||
|
case .requestSync: return "requestSync"
|
||||||
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Noise Payload Types
|
// MARK: - Noise Payload Types
|
||||||
|
|
||||||
@@ -98,6 +131,35 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
|
// Delivery status for messages
|
||||||
|
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
|
case sending
|
||||||
|
case sent // Left our device
|
||||||
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
|
case read(by: String, at: Date) // Seen by recipient
|
||||||
|
case failed(reason: String)
|
||||||
|
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||||
|
|
||||||
|
var displayText: String {
|
||||||
|
switch self {
|
||||||
|
case .sending:
|
||||||
|
return "Sending..."
|
||||||
|
case .sent:
|
||||||
|
return "Sent"
|
||||||
|
case .delivered(let nickname, _):
|
||||||
|
return "Delivered to \(nickname)"
|
||||||
|
case .read(let nickname, _):
|
||||||
|
return "Read by \(nickname)"
|
||||||
|
case .failed(let reason):
|
||||||
|
return "Failed: \(reason)"
|
||||||
|
case .partiallyDelivered(let reached, let total):
|
||||||
|
return "Delivered to \(reached)/\(total)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
@@ -116,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -133,7 +195,7 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,57 +119,4 @@ enum Geohash {
|
|||||||
}
|
}
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
|
||||||
/// - Parameter geohash: Base32 geohash string.
|
|
||||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
|
||||||
static func neighbors(of geohash: String) -> [String] {
|
|
||||||
guard !geohash.isEmpty else { return [] }
|
|
||||||
|
|
||||||
let precision = geohash.count
|
|
||||||
let bounds = decodeBounds(geohash)
|
|
||||||
let center = decodeCenter(geohash)
|
|
||||||
|
|
||||||
// Calculate cell dimensions
|
|
||||||
let latHeight = bounds.latMax - bounds.latMin
|
|
||||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
|
||||||
|
|
||||||
// Helper to wrap longitude around ±180
|
|
||||||
func wrapLongitude(_ lon: Double) -> Double {
|
|
||||||
var wrapped = lon
|
|
||||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
|
||||||
while wrapped < -180.0 { wrapped += 360.0 }
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to clamp latitude to ±90
|
|
||||||
func clampLatitude(_ lat: Double) -> Double {
|
|
||||||
return max(-90.0, min(90.0, lat))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate 8 neighbor centers
|
|
||||||
let neighbors: [(lat: Double, lon: Double)] = [
|
|
||||||
(center.lat + latHeight, center.lon), // N
|
|
||||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
|
||||||
(center.lat, center.lon + lonWidth), // E
|
|
||||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
|
||||||
(center.lat - latHeight, center.lon), // S
|
|
||||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
|
||||||
(center.lat, center.lon - lonWidth), // W
|
|
||||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
|
||||||
]
|
|
||||||
|
|
||||||
// Encode each neighbor, handling boundary conditions
|
|
||||||
return neighbors.compactMap { neighbor in
|
|
||||||
let lat = clampLatitude(neighbor.lat)
|
|
||||||
let lon = wrapLongitude(neighbor.lon)
|
|
||||||
|
|
||||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
|
||||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,18 +116,4 @@ enum ChannelID: Equatable, Codable {
|
|||||||
case .location(let ch): return ch.geohash
|
case .location(let ch): return ch.geohash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isMesh: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: true
|
|
||||||
case .location: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isLocation: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: false
|
|
||||||
case .location: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ struct AnnouncementPacket {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -38,16 +36,6 @@ struct AnnouncementPacket {
|
|||||||
data.append(UInt8(signingPublicKey.count))
|
data.append(UInt8(signingPublicKey.count))
|
||||||
data.append(signingPublicKey)
|
data.append(signingPublicKey)
|
||||||
|
|
||||||
// TLV for direct neighbors (optional)
|
|
||||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
|
||||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
|
||||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
|
||||||
data.append(TLVType.directNeighbors.rawValue)
|
|
||||||
data.append(UInt8(neighborsData.count))
|
|
||||||
data.append(neighborsData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +44,6 @@ struct AnnouncementPacket {
|
|||||||
var nickname: String?
|
var nickname: String?
|
||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -76,17 +63,6 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey = Data(value)
|
noisePublicKey = Data(value)
|
||||||
case .signingPublicKey:
|
case .signingPublicKey:
|
||||||
signingPublicKey = Data(value)
|
signingPublicKey = Data(value)
|
||||||
case .directNeighbors:
|
|
||||||
if length > 0 && length % 8 == 0 {
|
|
||||||
var neighbors = [Data]()
|
|
||||||
let count = length / 8
|
|
||||||
for i in 0..<count {
|
|
||||||
let start = value.startIndex + i * 8
|
|
||||||
let end = start + 8
|
|
||||||
neighbors.append(Data(value[start..<end]))
|
|
||||||
}
|
|
||||||
directNeighbors = neighbors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -98,8 +74,7 @@ struct AnnouncementPacket {
|
|||||||
return AnnouncementPacket(
|
return AnnouncementPacket(
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey
|
||||||
directNeighbors: directNeighbors
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Narrow environment for `BLEAnnounceHandler`.
|
|
||||||
///
|
|
||||||
/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor
|
|
||||||
/// UI notification, delayed re-announce) live inside the closures supplied by
|
|
||||||
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
|
|
||||||
struct BLEAnnounceHandlerEnvironment {
|
|
||||||
/// Local peer identity at the time the announce is handled.
|
|
||||||
let localPeerID: () -> PeerID
|
|
||||||
/// TTL value used for direct (non-relayed) packets.
|
|
||||||
let messageTTL: UInt8
|
|
||||||
/// Current time source.
|
|
||||||
let now: () -> Date
|
|
||||||
/// Noise public key already recorded for the peer, if any (registry read).
|
|
||||||
let existingNoisePublicKey: (PeerID) -> Data?
|
|
||||||
/// Verifies the packet signature against the announced signing key.
|
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
|
||||||
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
|
||||||
/// Runs the registry mutation phase under the collections barrier.
|
|
||||||
let withRegistryBarrier: (() -> Void) -> Void
|
|
||||||
/// Upserts the verified announce into the peer registry.
|
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
|
||||||
let upsertVerifiedAnnounce: (
|
|
||||||
_ peerID: PeerID,
|
|
||||||
_ announcement: AnnouncementPacket,
|
|
||||||
_ isConnected: Bool,
|
|
||||||
_ now: Date
|
|
||||||
) -> BLEPeerAnnounceUpdate
|
|
||||||
/// Debounced reconnect-log decision.
|
|
||||||
/// Must only be called from inside `withRegistryBarrier`.
|
|
||||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
|
||||||
/// Records verified direct-neighbor claims in the mesh topology.
|
|
||||||
let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void
|
|
||||||
/// Persists the announced cryptographic identity for offline verification.
|
|
||||||
let persistIdentity: (AnnouncementPacket) -> Void
|
|
||||||
/// Announce-back dedup check.
|
|
||||||
let dedupContains: (String) -> Bool
|
|
||||||
/// Announce-back dedup marking.
|
|
||||||
let dedupMarkProcessed: (String) -> Void
|
|
||||||
/// Delivers the announce UI events as one ordered main-actor hop:
|
|
||||||
/// `.peerConnected` (if flagged) → initial gossip sync scheduling (if
|
|
||||||
/// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`.
|
|
||||||
/// A single closure keeps the original in-order delivery guarantee that
|
|
||||||
/// separate unstructured tasks would not provide.
|
|
||||||
let deliverAnnounceUIEvents: (
|
|
||||||
_ peerID: PeerID,
|
|
||||||
_ notifyPeerConnected: Bool,
|
|
||||||
_ scheduleInitialSync: Bool
|
|
||||||
) -> Void
|
|
||||||
/// Tracks the announce packet for gossip sync.
|
|
||||||
let trackPacketSeen: (BitchatPacket) -> Void
|
|
||||||
/// Reciprocates the announce for bidirectional discovery.
|
|
||||||
let sendAnnounceBack: () -> Void
|
|
||||||
/// Schedules a delayed re-announce (afterglow) after the given delay.
|
|
||||||
let scheduleAfterglow: (TimeInterval) -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
|
||||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
|
||||||
/// gossip tracking, and the reciprocal announce response.
|
|
||||||
final class BLEAnnounceHandler {
|
|
||||||
private let environment: BLEAnnounceHandlerEnvironment
|
|
||||||
|
|
||||||
init(environment: BLEAnnounceHandlerEnvironment) {
|
|
||||||
self.environment = environment
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
||||||
let env = environment
|
|
||||||
let now = env.now()
|
|
||||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
|
||||||
packet: packet,
|
|
||||||
from: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
now: now
|
|
||||||
)
|
|
||||||
|
|
||||||
let announcement: AnnouncementPacket
|
|
||||||
switch preflight {
|
|
||||||
case .accept(let acceptance):
|
|
||||||
announcement = acceptance.announcement
|
|
||||||
case .reject(.malformed):
|
|
||||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
|
||||||
return
|
|
||||||
case .reject(.senderMismatch(let derivedFromKey)):
|
|
||||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
case .reject(.selfAnnounce):
|
|
||||||
return
|
|
||||||
case .reject(.stale(let ageSeconds)):
|
|
||||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
|
||||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
|
||||||
let hasSignature = packet.signature != nil
|
|
||||||
let signatureValid: Bool
|
|
||||||
if hasSignature {
|
|
||||||
signatureValid = env.verifySignature(packet, announcement.signingPublicKey)
|
|
||||||
if !signatureValid {
|
|
||||||
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
signatureValid = false
|
|
||||||
}
|
|
||||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
|
||||||
hasSignature: hasSignature,
|
|
||||||
signatureValid: signatureValid,
|
|
||||||
existingNoisePublicKey: existingNoisePublicKey,
|
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey
|
|
||||||
)
|
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
|
||||||
}
|
|
||||||
let verifiedAnnounce = trustDecision.isVerified
|
|
||||||
|
|
||||||
var isNewPeer = false
|
|
||||||
var isReconnectedPeer = false
|
|
||||||
let directLinkState = env.linkState(peerID)
|
|
||||||
let isDirectAnnounce = packet.ttl == env.messageTTL
|
|
||||||
|
|
||||||
env.withRegistryBarrier {
|
|
||||||
let hasPeripheralConnection = directLinkState.hasPeripheral
|
|
||||||
let hasCentralSubscription = directLinkState.hasCentral
|
|
||||||
|
|
||||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
|
||||||
if !verifiedAnnounce {
|
|
||||||
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
// Reset flags to prevent post-barrier code from acting on unverified announces
|
|
||||||
isNewPeer = false
|
|
||||||
isReconnectedPeer = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let update = env.upsertVerifiedAnnounce(
|
|
||||||
peerID,
|
|
||||||
announcement,
|
|
||||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
isNewPeer = update.isNewPeer
|
|
||||||
isReconnectedPeer = update.wasDisconnected
|
|
||||||
|
|
||||||
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
|
||||||
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
|
||||||
let now = env.now()
|
|
||||||
if update.isNewPeer {
|
|
||||||
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
|
||||||
} else if update.wasDisconnected {
|
|
||||||
if env.shouldEmitReconnectLog(peerID, now) {
|
|
||||||
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
|
||||||
}
|
|
||||||
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
|
|
||||||
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update topology with verified neighbor claims (only for authenticated announces)
|
|
||||||
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
|
|
||||||
env.updateTopology(peerID, neighbors)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist cryptographic identity and signing key for robust offline
|
|
||||||
// verification — only for verified announces. Persisting unverified
|
|
||||||
// announces would let an attacker who replays a victim's noisePublicKey
|
|
||||||
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
|
||||||
if verifiedAnnounce {
|
|
||||||
env.persistIdentity(announcement)
|
|
||||||
}
|
|
||||||
|
|
||||||
let announceBackID = "announce-back-\(peerID)"
|
|
||||||
let shouldSendBack = !env.dedupContains(announceBackID)
|
|
||||||
if shouldSendBack {
|
|
||||||
env.dedupMarkProcessed(announceBackID)
|
|
||||||
}
|
|
||||||
let responsePlan = BLEAnnounceResponsePolicy.plan(
|
|
||||||
isDirectAnnounce: isDirectAnnounce,
|
|
||||||
isNewPeer: isNewPeer,
|
|
||||||
isReconnectedPeer: isReconnectedPeer,
|
|
||||||
shouldSendAnnounceBack: shouldSendBack
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only notify of connection for new or reconnected peers when it is a
|
|
||||||
// direct announce; the list update always follows in the same hop.
|
|
||||||
env.deliverAnnounceUIEvents(
|
|
||||||
peerID,
|
|
||||||
responsePlan.shouldNotifyPeerConnected,
|
|
||||||
responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync
|
|
||||||
)
|
|
||||||
|
|
||||||
// Track for sync (include our own and others' announces)
|
|
||||||
env.trackPacketSeen(packet)
|
|
||||||
|
|
||||||
if responsePlan.shouldSendAnnounceBack {
|
|
||||||
// Reciprocate announce for bidirectional discovery
|
|
||||||
// Force send to ensure the peer receives our announce
|
|
||||||
env.sendAnnounceBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
|
||||||
if responsePlan.shouldScheduleAfterglow {
|
|
||||||
let delay = Double.random(in: 0.3...0.6)
|
|
||||||
env.scheduleAfterglow(delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEAnnouncePreflightAcceptance {
|
|
||||||
let announcement: AnnouncementPacket
|
|
||||||
let derivedPeerID: PeerID
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnouncePreflightRejection: Equatable {
|
|
||||||
case malformed
|
|
||||||
case senderMismatch(derivedPeerID: PeerID)
|
|
||||||
case selfAnnounce
|
|
||||||
case stale(ageSeconds: Double)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnouncePreflightDecision {
|
|
||||||
case accept(BLEAnnouncePreflightAcceptance)
|
|
||||||
case reject(BLEAnnouncePreflightRejection)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnouncePreflightPolicy {
|
|
||||||
static func evaluate(
|
|
||||||
packet: BitchatPacket,
|
|
||||||
from peerID: PeerID,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
now: Date
|
|
||||||
) -> BLEAnnouncePreflightDecision {
|
|
||||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
|
||||||
return .reject(.malformed)
|
|
||||||
}
|
|
||||||
|
|
||||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
|
||||||
guard derivedPeerID == peerID else {
|
|
||||||
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard peerID != localPeerID else {
|
|
||||||
return .reject(.selfAnnounce)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
|
||||||
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
|
||||||
timestampMilliseconds: packet.timestamp,
|
|
||||||
now: now
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return .accept(BLEAnnouncePreflightAcceptance(
|
|
||||||
announcement: announcement,
|
|
||||||
derivedPeerID: derivedPeerID
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnounceTrustRejection: Equatable {
|
|
||||||
case missingSignature
|
|
||||||
case invalidSignature
|
|
||||||
case keyMismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
|
||||||
case verified
|
|
||||||
case reject(BLEAnnounceTrustRejection)
|
|
||||||
|
|
||||||
var isVerified: Bool {
|
|
||||||
self == .verified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnounceTrustPolicy {
|
|
||||||
static func evaluate(
|
|
||||||
hasSignature: Bool,
|
|
||||||
signatureValid: Bool,
|
|
||||||
existingNoisePublicKey: Data?,
|
|
||||||
announcedNoisePublicKey: Data
|
|
||||||
) -> BLEAnnounceTrustDecision {
|
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
|
||||||
return .reject(.keyMismatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard hasSignature else {
|
|
||||||
return .reject(.missingSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard signatureValid else {
|
|
||||||
return .reject(.invalidSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
return .verified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEAnnounceResponsePlan: Equatable {
|
|
||||||
let shouldNotifyPeerConnected: Bool
|
|
||||||
let shouldScheduleInitialSync: Bool
|
|
||||||
let shouldSendAnnounceBack: Bool
|
|
||||||
let shouldScheduleAfterglow: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEAnnounceResponsePolicy {
|
|
||||||
static func plan(
|
|
||||||
isDirectAnnounce: Bool,
|
|
||||||
isNewPeer: Bool,
|
|
||||||
isReconnectedPeer: Bool,
|
|
||||||
shouldSendAnnounceBack: Bool
|
|
||||||
) -> BLEAnnounceResponsePlan {
|
|
||||||
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
|
||||||
|
|
||||||
return BLEAnnounceResponsePlan(
|
|
||||||
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
|
||||||
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
|
||||||
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
|
||||||
shouldScheduleAfterglow: isNewPeer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEAnnounceThrottle {
|
|
||||||
private var lastSent: Date
|
|
||||||
private let normalMinimumInterval: TimeInterval
|
|
||||||
private let forcedMinimumInterval: TimeInterval
|
|
||||||
|
|
||||||
init(
|
|
||||||
lastSent: Date = .distantPast,
|
|
||||||
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
|
|
||||||
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
|
|
||||||
) {
|
|
||||||
self.lastSent = lastSent
|
|
||||||
self.normalMinimumInterval = normalMinimumInterval
|
|
||||||
self.forcedMinimumInterval = forcedMinimumInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
func elapsed(since now: Date) -> TimeInterval {
|
|
||||||
now.timeIntervalSince(lastSent)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
|
||||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
|
||||||
guard elapsed(since: now) >= minimumInterval else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSent = now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEConnectionCandidate<Peripheral> {
|
|
||||||
let peripheral: Peripheral
|
|
||||||
let peripheralID: String
|
|
||||||
let rssi: Int
|
|
||||||
let name: String
|
|
||||||
let isConnectable: Bool
|
|
||||||
let discoveredAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEExistingConnectionState {
|
|
||||||
let isConnecting: Bool
|
|
||||||
let isConnected: Bool
|
|
||||||
let lastConnectionAttempt: Date?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEPeripheralConnectionState {
|
|
||||||
case disconnected
|
|
||||||
case connecting
|
|
||||||
case connected
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEDiscoveryDecision: Equatable {
|
|
||||||
case ignore
|
|
||||||
case queued
|
|
||||||
case scheduleRetry(after: TimeInterval)
|
|
||||||
case cancelStaleConnection
|
|
||||||
case connectNow
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEConnectionQueueDecision<Peripheral> {
|
|
||||||
case none
|
|
||||||
case retryAfter(TimeInterval)
|
|
||||||
case connect(BLEConnectionCandidate<Peripheral>)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class BLEConnectionScheduler<Peripheral> {
|
|
||||||
private let maxCentralLinks: Int
|
|
||||||
private let connectRateLimitInterval: TimeInterval
|
|
||||||
private let candidateCap: Int
|
|
||||||
private let weakLinkCooldownSeconds: TimeInterval
|
|
||||||
private let weakLinkRSSICutoff: Int
|
|
||||||
|
|
||||||
private var lastGlobalConnectAttempt: Date = .distantPast
|
|
||||||
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
|
|
||||||
private var failureCounts: [String: Int] = [:]
|
|
||||||
private var recentConnectTimeouts: [String: Date] = [:]
|
|
||||||
// Tracked separately from connect timeouts: a peer we held a connection
|
|
||||||
// with and lost (walked out of range) usually comes back, so it only gets
|
|
||||||
// a brief rediscovery ignore — not the timeout backoff/cooldown treatment
|
|
||||||
// reserved for peers that never answered a connect attempt.
|
|
||||||
private var recentDisconnects: [String: Date] = [:]
|
|
||||||
private var lastIsolatedAt: Date?
|
|
||||||
|
|
||||||
private let initialDynamicRSSIThreshold: Int
|
|
||||||
private(set) var dynamicRSSIThreshold: Int
|
|
||||||
|
|
||||||
var candidateCount: Int {
|
|
||||||
candidates.count
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
|
|
||||||
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
|
|
||||||
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
|
|
||||||
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
|
|
||||||
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
|
|
||||||
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
|
|
||||||
) {
|
|
||||||
self.maxCentralLinks = maxCentralLinks
|
|
||||||
self.connectRateLimitInterval = connectRateLimitInterval
|
|
||||||
self.candidateCap = candidateCap
|
|
||||||
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
|
|
||||||
self.weakLinkRSSICutoff = weakLinkRSSICutoff
|
|
||||||
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
|
|
||||||
self.dynamicRSSIThreshold = dynamicRSSIThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleDiscovery(
|
|
||||||
_ candidate: BLEConnectionCandidate<Peripheral>,
|
|
||||||
connectedOrConnectingCount: Int,
|
|
||||||
existingState: BLEExistingConnectionState?,
|
|
||||||
peripheralState: BLEPeripheralConnectionState,
|
|
||||||
now: Date
|
|
||||||
) -> BLEDiscoveryDecision {
|
|
||||||
guard candidate.isConnectable else { return .ignore }
|
|
||||||
|
|
||||||
if candidate.rssi <= dynamicRSSIThreshold {
|
|
||||||
enqueue(candidate)
|
|
||||||
return .queued
|
|
||||||
}
|
|
||||||
|
|
||||||
if connectedOrConnectingCount >= maxCentralLinks {
|
|
||||||
enqueue(candidate)
|
|
||||||
return .queued
|
|
||||||
}
|
|
||||||
|
|
||||||
if let retryDelay = rateLimitRetryDelay(now: now) {
|
|
||||||
enqueue(candidate)
|
|
||||||
return .scheduleRetry(after: retryDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let existingState {
|
|
||||||
if existingState.isConnected || existingState.isConnecting {
|
|
||||||
return .ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastAttempt = existingState.lastConnectionAttempt,
|
|
||||||
now.timeIntervalSince(lastAttempt) < 2.0 {
|
|
||||||
return .ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
|
|
||||||
now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds {
|
|
||||||
return .ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastDisconnect = recentDisconnects[candidate.peripheralID],
|
|
||||||
now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds {
|
|
||||||
return .ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
switch peripheralState {
|
|
||||||
case .disconnected:
|
|
||||||
return .connectNow
|
|
||||||
case .connecting, .connected:
|
|
||||||
return .cancelStaleConnection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
|
|
||||||
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
|
|
||||||
candidates[existingIndex] = candidate
|
|
||||||
} else {
|
|
||||||
candidates.append(candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates.sort {
|
|
||||||
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
|
|
||||||
return $0.discoveredAt < $1.discoveredAt
|
|
||||||
}
|
|
||||||
if candidates.count > candidateCap {
|
|
||||||
candidates.removeLast(candidates.count - candidateCap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextCandidate(
|
|
||||||
connectedOrConnectingCount: Int,
|
|
||||||
isAlreadyConnectingOrConnected: (String) -> Bool,
|
|
||||||
now: Date
|
|
||||||
) -> BLEConnectionQueueDecision<Peripheral> {
|
|
||||||
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
|
|
||||||
|
|
||||||
if let retryDelay = rateLimitRetryDelay(now: now) {
|
|
||||||
return .retryAfter(retryDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
while !candidates.isEmpty {
|
|
||||||
candidates.sort { score($0, now: now) > score($1, now: now) }
|
|
||||||
let candidate = candidates.removeFirst()
|
|
||||||
guard candidate.isConnectable else { continue }
|
|
||||||
|
|
||||||
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
|
|
||||||
enqueue(candidate)
|
|
||||||
return .retryAfter(delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let delay = disconnectSettleDelay(for: candidate, now: now) {
|
|
||||||
enqueue(candidate)
|
|
||||||
return .retryAfter(delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return .connect(candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
return .none
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordConnectionAttempt(at now: Date) {
|
|
||||||
lastGlobalConnectAttempt = now
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordConnectionSuccess(peripheralID: String) {
|
|
||||||
failureCounts[peripheralID] = 0
|
|
||||||
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
|
||||||
recentDisconnects.removeValue(forKey: peripheralID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordConnectionFailure(peripheralID: String) {
|
|
||||||
failureCounts[peripheralID, default: 0] += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordDisconnectError(peripheralID: String, at now: Date) {
|
|
||||||
recentDisconnects[peripheralID] = now
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordConnectionTimeout(peripheralID: String, at now: Date) {
|
|
||||||
recentConnectTimeouts[peripheralID] = now
|
|
||||||
recordConnectionFailure(peripheralID: peripheralID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneConnectionTimeouts(before cutoff: Date) {
|
|
||||||
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
|
|
||||||
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
lastGlobalConnectAttempt = .distantPast
|
|
||||||
candidates.removeAll()
|
|
||||||
failureCounts.removeAll()
|
|
||||||
recentConnectTimeouts.removeAll()
|
|
||||||
recentDisconnects.removeAll()
|
|
||||||
lastIsolatedAt = nil
|
|
||||||
dynamicRSSIThreshold = initialDynamicRSSIThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func updateRSSIThreshold(
|
|
||||||
connectedCount: Int,
|
|
||||||
connectedOrConnectingLinkCount: Int,
|
|
||||||
now: Date
|
|
||||||
) -> Int {
|
|
||||||
if connectedCount == 0 {
|
|
||||||
if lastIsolatedAt == nil { lastIsolatedAt = now }
|
|
||||||
let isolatedAt = lastIsolatedAt ?? now
|
|
||||||
let elapsed = now.timeIntervalSince(isolatedAt)
|
|
||||||
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
|
|
||||||
? TransportConfig.bleRSSIIsolatedRelaxed
|
|
||||||
: TransportConfig.bleRSSIIsolatedBase
|
|
||||||
return dynamicRSSIThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIsolatedAt = nil
|
|
||||||
// Flaky links are handled per-peripheral (weak-link cooldown, discovery
|
|
||||||
// ignore window, score bias) — never globally, so one flaky distant peer
|
|
||||||
// can't blind us to every other edge-of-range peer.
|
|
||||||
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
|
|
||||||
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
|
|
||||||
threshold = TransportConfig.bleRSSIConnectedThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
dynamicRSSIThreshold = threshold
|
|
||||||
return threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
|
|
||||||
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
|
|
||||||
guard elapsed < connectRateLimitInterval else { return nil }
|
|
||||||
return connectRateLimitInterval - elapsed + 0.05
|
|
||||||
}
|
|
||||||
|
|
||||||
private func weakLinkRetryDelay(
|
|
||||||
for candidate: BLEConnectionCandidate<Peripheral>,
|
|
||||||
now: Date
|
|
||||||
) -> TimeInterval? {
|
|
||||||
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
|
|
||||||
let elapsed = now.timeIntervalSince(lastTimeout)
|
|
||||||
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
|
|
||||||
let remaining = weakLinkCooldownSeconds - elapsed
|
|
||||||
return min(max(2.0, remaining), 15.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The disconnect settle window must hold on the queue path too: a stale
|
|
||||||
// candidate enqueued while the peripheral was still connected would
|
|
||||||
// otherwise reconnect immediately via the post-disconnect queue drain,
|
|
||||||
// bypassing the window and recreating reconnect/cancel thrash.
|
|
||||||
private func disconnectSettleDelay(
|
|
||||||
for candidate: BLEConnectionCandidate<Peripheral>,
|
|
||||||
now: Date
|
|
||||||
) -> TimeInterval? {
|
|
||||||
guard let lastDisconnect = recentDisconnects[candidate.peripheralID] else { return nil }
|
|
||||||
let remaining = TransportConfig.bleDisconnectDiscoveryIgnoreSeconds - now.timeIntervalSince(lastDisconnect)
|
|
||||||
guard remaining > 0 else { return nil }
|
|
||||||
return remaining + 0.05
|
|
||||||
}
|
|
||||||
|
|
||||||
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
|
|
||||||
let failures = failureCounts[candidate.peripheralID] ?? 0
|
|
||||||
let penalty = min(20, 1 << min(4, failures))
|
|
||||||
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
|
|
||||||
now.timeIntervalSince($0) < 60 ? 10 : 0
|
|
||||||
} ?? 0
|
|
||||||
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
|
|
||||||
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
|
|
||||||
return base + recency - penalty - timeoutBias
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEDirectedRelaySpoolEntry {
|
|
||||||
let recipient: PeerID
|
|
||||||
let packet: BitchatPacket
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEDirectedRelaySpool {
|
|
||||||
private struct StoredPacket {
|
|
||||||
let packet: BitchatPacket
|
|
||||||
let enqueuedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
packetsByRecipient.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
packetsByRecipient.values.reduce(0) { $0 + $1.count }
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func enqueue(
|
|
||||||
packet: BitchatPacket,
|
|
||||||
recipient: PeerID,
|
|
||||||
messageID: String,
|
|
||||||
enqueuedAt: Date
|
|
||||||
) -> Bool {
|
|
||||||
var packets = packetsByRecipient[recipient] ?? [:]
|
|
||||||
guard packets[messageID] == nil else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
|
|
||||||
packetsByRecipient[recipient] = packets
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
|
|
||||||
var entries: [BLEDirectedRelaySpoolEntry] = []
|
|
||||||
|
|
||||||
for (recipient, packets) in packetsByRecipient {
|
|
||||||
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
|
|
||||||
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
packetsByRecipient.removeAll()
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func pruneExpired(now: Date, window: TimeInterval) {
|
|
||||||
guard !packetsByRecipient.isEmpty else { return }
|
|
||||||
|
|
||||||
var pruned: [PeerID: [String: StoredPacket]] = [:]
|
|
||||||
for (recipient, packets) in packetsByRecipient {
|
|
||||||
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
|
|
||||||
if !freshPackets.isEmpty {
|
|
||||||
pruned[recipient] = freshPackets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packetsByRecipient = pruned
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
packetsByRecipient.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEFanoutSelection: Equatable {
|
|
||||||
let peripheralIDs: Set<String>
|
|
||||||
let centralIDs: Set<String>
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEFanoutSelector {
|
|
||||||
static func selectLinks(
|
|
||||||
peripheralIDs: [String],
|
|
||||||
centralIDs: [String],
|
|
||||||
ingressLink: BLEIngressLinkID?,
|
|
||||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
|
||||||
peripheralPeerBindings: [String: PeerID] = [:],
|
|
||||||
centralPeerBindings: [String: PeerID] = [:],
|
|
||||||
directedPeerHint: PeerID?,
|
|
||||||
packetType: UInt8,
|
|
||||||
messageID: String
|
|
||||||
) -> BLEFanoutSelection {
|
|
||||||
let allowed = collapseDuplicateLinksPerPeer(
|
|
||||||
allowedLinks(
|
|
||||||
peripheralIDs: peripheralIDs,
|
|
||||||
centralIDs: centralIDs,
|
|
||||||
ingressLink: ingressLink,
|
|
||||||
excludedLinks: excludedLinks
|
|
||||||
),
|
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
|
||||||
centralPeerBindings: centralPeerBindings
|
|
||||||
)
|
|
||||||
|
|
||||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
|
||||||
return BLEFanoutSelection(
|
|
||||||
peripheralIDs: Set(allowed.peripheralIDs),
|
|
||||||
centralIDs: Set(allowed.centralIDs)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BLEFanoutSelection(
|
|
||||||
peripheralIDs: deterministicSubset(
|
|
||||||
ids: allowed.peripheralIDs,
|
|
||||||
k: subsetSize(for: allowed.peripheralIDs.count),
|
|
||||||
seed: messageID
|
|
||||||
),
|
|
||||||
centralIDs: deterministicSubset(
|
|
||||||
ids: allowed.centralIDs,
|
|
||||||
k: subsetSize(for: allowed.centralIDs.count),
|
|
||||||
seed: messageID
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func allowedLinks(
|
|
||||||
peripheralIDs: [String],
|
|
||||||
centralIDs: [String],
|
|
||||||
ingressLink: BLEIngressLinkID?,
|
|
||||||
excludedLinks: Set<BLEIngressLinkID>
|
|
||||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
|
||||||
var allowedPeripheralIDs = peripheralIDs
|
|
||||||
var allowedCentralIDs = centralIDs
|
|
||||||
var blockedLinks = excludedLinks
|
|
||||||
|
|
||||||
if let ingressLink {
|
|
||||||
blockedLinks.insert(ingressLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
|
|
||||||
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
|
|
||||||
|
|
||||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
|
||||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
|
||||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
|
||||||
// and deduplicator just discard the copy. Keep one link per bound peer,
|
|
||||||
// preferring the peripheral (write) side: it has per-link flow control
|
|
||||||
// via canSendWriteWithoutResponse, while notifications share the
|
|
||||||
// peripheral manager's update queue across all centrals. Links with no
|
|
||||||
// bound peer yet (pre-announce) pass through untouched.
|
|
||||||
private static func collapseDuplicateLinksPerPeer(
|
|
||||||
_ links: (peripheralIDs: [String], centralIDs: [String]),
|
|
||||||
peripheralPeerBindings: [String: PeerID],
|
|
||||||
centralPeerBindings: [String: PeerID]
|
|
||||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
|
||||||
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
|
|
||||||
return links
|
|
||||||
}
|
|
||||||
|
|
||||||
var seenPeers = Set<PeerID>()
|
|
||||||
var keptPeripheralIDs: [String] = []
|
|
||||||
for id in links.peripheralIDs {
|
|
||||||
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keptPeripheralIDs.append(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
var keptCentralIDs: [String] = []
|
|
||||||
for id in links.centralIDs {
|
|
||||||
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keptCentralIDs.append(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (keptPeripheralIDs, keptCentralIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
|
|
||||||
directedPeerHint == nil
|
|
||||||
&& packetType != MessageType.fragment.rawValue
|
|
||||||
&& packetType != MessageType.announce.rawValue
|
|
||||||
&& packetType != MessageType.requestSync.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func subsetSize(for count: Int) -> Int {
|
|
||||||
guard count > 0 else { return 0 }
|
|
||||||
if count <= 2 { return count }
|
|
||||||
|
|
||||||
var value = count - 1
|
|
||||||
var bits = 0
|
|
||||||
while value > 0 {
|
|
||||||
value >>= 1
|
|
||||||
bits += 1
|
|
||||||
}
|
|
||||||
return min(count, max(1, bits + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
|
||||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
|
||||||
|
|
||||||
var scored: [(score: [UInt8], id: String)] = []
|
|
||||||
for id in ids {
|
|
||||||
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
|
|
||||||
let digest = Array(SHA256.hash(data: data))
|
|
||||||
scored.append((digest, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
scored.sort { lhs, rhs in
|
|
||||||
for index in 0..<min(lhs.score.count, rhs.score.count) {
|
|
||||||
if lhs.score[index] != rhs.score[index] {
|
|
||||||
return lhs.score[index] < rhs.score[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lhs.id < rhs.id
|
|
||||||
}
|
|
||||||
|
|
||||||
return Set(scored.prefix(k).map(\.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Narrow environment for `BLEFileTransferHandler`.
|
|
||||||
///
|
|
||||||
/// All queue hops (collections registry reads/writes, main-actor UI
|
|
||||||
/// notification) live inside the closures supplied by `BLEService`, keeping
|
|
||||||
/// the handler queue-agnostic and synchronously testable.
|
|
||||||
struct BLEFileTransferHandlerEnvironment {
|
|
||||||
/// Local peer identity at the time the transfer is handled.
|
|
||||||
let localPeerID: () -> PeerID
|
|
||||||
/// Local nickname used for sender resolution and collision checks.
|
|
||||||
let localNickname: () -> String
|
|
||||||
/// Snapshot of known peers keyed by ID (registry read).
|
|
||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
|
||||||
let trackPacketSeen: (BitchatPacket) -> Void
|
|
||||||
/// Enforces the incoming-media storage quota before saving (BCH-01-002).
|
|
||||||
let enforceStorageQuota: (_ reservingBytes: Int) -> Void
|
|
||||||
/// Persists the validated file to the incoming-media store; returns the destination URL.
|
|
||||||
let saveIncomingFile: (
|
|
||||||
_ data: Data,
|
|
||||||
_ preferredName: String?,
|
|
||||||
_ subdirectory: String,
|
|
||||||
_ fallbackExtension: String?,
|
|
||||||
_ defaultPrefix: String
|
|
||||||
) -> URL?
|
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
|
||||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
|
||||||
let deliverMessage: (BitchatMessage) -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
|
||||||
/// resolution, delivery planning, payload validation, quota-checked storage,
|
|
||||||
/// and UI delivery.
|
|
||||||
final class BLEFileTransferHandler {
|
|
||||||
private let environment: BLEFileTransferHandlerEnvironment
|
|
||||||
|
|
||||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
|
||||||
self.environment = environment
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
||||||
let env = environment
|
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peersSnapshot,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
|
||||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if deliveryPlan.shouldTrackForSync {
|
|
||||||
env.trackPacketSeen(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
|
||||||
let mime: MimeType
|
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
|
||||||
case .success(let acceptance):
|
|
||||||
filePacket = acceptance.filePacket
|
|
||||||
mime = acceptance.mime
|
|
||||||
case .failure(.malformedPayload):
|
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
|
||||||
return
|
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
|
||||||
return
|
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
|
||||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
|
||||||
return
|
|
||||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
|
||||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
|
||||||
env.enforceStorageQuota(filePacket.content.count)
|
|
||||||
|
|
||||||
guard let destination = env.saveIncomingFile(
|
|
||||||
filePacket.content,
|
|
||||||
filePacket.fileName,
|
|
||||||
"\(mime.category.mediaDir)/incoming",
|
|
||||||
mime.defaultExtension,
|
|
||||||
mime.category.rawValue
|
|
||||||
) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
|
||||||
env.updatePeerLastSeen(peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
|
||||||
let message = BitchatMessage(
|
|
||||||
sender: senderNickname,
|
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
|
||||||
timestamp: ts,
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
|
||||||
|
|
||||||
env.deliverMessage(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEFileTransferDeliveryPlan: Equatable {
|
|
||||||
let isPrivateMessage: Bool
|
|
||||||
let shouldTrackForSync: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEFileTransferPolicy {
|
|
||||||
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
|
|
||||||
peerID == localPeerID && packet.ttl != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
|
|
||||||
guard let recipientID = packet.recipientID else {
|
|
||||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
|
|
||||||
if isBroadcast {
|
|
||||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard PeerID(hexData: recipientID) == localPeerID else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEIncomingFileAcceptance {
|
|
||||||
let filePacket: BitchatFilePacket
|
|
||||||
let mime: MimeType
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEIncomingFileRejection: Error, Equatable {
|
|
||||||
case malformedPayload
|
|
||||||
case payloadTooLarge(bytes: Int)
|
|
||||||
case unsupportedMime(mimeType: String?, bytes: Int)
|
|
||||||
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
|
||||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
|
||||||
return .failure(.malformedPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
|
||||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
|
|
||||||
return .failure(.unsupportedMime(
|
|
||||||
mimeType: filePacket.mimeType,
|
|
||||||
bytes: filePacket.content.count
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard mime.matches(data: filePacket.content) else {
|
|
||||||
return .failure(.magicMismatch(
|
|
||||||
mime: mime,
|
|
||||||
bytes: filePacket.content.count,
|
|
||||||
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEFragmentKey: Hashable, Equatable {
|
|
||||||
let sender: UInt64
|
|
||||||
let id: UInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEFragmentHeader: Equatable {
|
|
||||||
let key: BLEFragmentKey
|
|
||||||
let index: Int
|
|
||||||
let total: Int
|
|
||||||
let originalType: UInt8
|
|
||||||
let fragmentData: Data
|
|
||||||
let isBroadcastFragment: Bool
|
|
||||||
|
|
||||||
var idLogString: String {
|
|
||||||
String(format: "%016llx", key.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(packet: BitchatPacket) {
|
|
||||||
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
|
|
||||||
guard packet.payload.count >= 13 else { return nil }
|
|
||||||
|
|
||||||
var senderU64: UInt64 = 0
|
|
||||||
for byte in packet.senderID.prefix(8) {
|
|
||||||
senderU64 = (senderU64 << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
|
|
||||||
var fragmentU64: UInt64 = 0
|
|
||||||
for byte in packet.payload.prefix(8) {
|
|
||||||
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
|
|
||||||
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
|
|
||||||
|
|
||||||
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let isBroadcastFragment: Bool = {
|
|
||||||
guard let recipient = packet.recipientID else { return true }
|
|
||||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
|
||||||
}()
|
|
||||||
|
|
||||||
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
|
|
||||||
self.index = index
|
|
||||||
self.total = total
|
|
||||||
self.originalType = packet.payload[12]
|
|
||||||
self.fragmentData = Data(packet.payload.suffix(from: 13))
|
|
||||||
self.isBroadcastFragment = isBroadcastFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEFragmentAssemblyBuffer {
|
|
||||||
enum AppendResult: Equatable {
|
|
||||||
case stored(header: BLEFragmentHeader, started: Bool)
|
|
||||||
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
|
|
||||||
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Metadata {
|
|
||||||
let type: UInt8
|
|
||||||
let total: Int
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
|
||||||
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
fragmentsByKey.removeAll()
|
|
||||||
metadataByKey.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func removeExpired(before cutoff: Date) -> Int {
|
|
||||||
let expiredKeys = metadataByKey
|
|
||||||
.filter { $0.value.timestamp < cutoff }
|
|
||||||
.map(\.key)
|
|
||||||
|
|
||||||
for key in expiredKeys {
|
|
||||||
fragmentsByKey.removeValue(forKey: key)
|
|
||||||
metadataByKey.removeValue(forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return expiredKeys.count
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(
|
|
||||||
_ header: BLEFragmentHeader,
|
|
||||||
maxInFlightAssemblies: Int,
|
|
||||||
now: Date = Date()
|
|
||||||
) -> AppendResult {
|
|
||||||
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
|
|
||||||
|
|
||||||
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
|
||||||
let limit = Self.assemblyLimit(for: header.originalType)
|
|
||||||
let projectedSize = currentSize + header.fragmentData.count
|
|
||||||
|
|
||||||
guard projectedSize <= limit else {
|
|
||||||
fragmentsByKey.removeValue(forKey: header.key)
|
|
||||||
metadataByKey.removeValue(forKey: header.key)
|
|
||||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
|
||||||
}
|
|
||||||
|
|
||||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
|
||||||
|
|
||||||
guard let fragments = fragmentsByKey[header.key],
|
|
||||||
fragments.count == header.total else {
|
|
||||||
return .stored(header: header, started: started)
|
|
||||||
}
|
|
||||||
|
|
||||||
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
|
|
||||||
if let fragment = fragments[index] {
|
|
||||||
data.append(fragment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fragmentsByKey.removeValue(forKey: header.key)
|
|
||||||
metadataByKey.removeValue(forKey: header.key)
|
|
||||||
|
|
||||||
return .complete(header: header, reassembledData: reassembled, started: started)
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func startAssemblyIfNeeded(
|
|
||||||
for header: BLEFragmentHeader,
|
|
||||||
maxInFlightAssemblies: Int,
|
|
||||||
now: Date
|
|
||||||
) -> Bool {
|
|
||||||
guard fragmentsByKey[header.key] == nil else { return false }
|
|
||||||
|
|
||||||
if fragmentsByKey.count >= maxInFlightAssemblies,
|
|
||||||
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
|
||||||
fragmentsByKey.removeValue(forKey: oldest)
|
|
||||||
metadataByKey.removeValue(forKey: oldest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
|
||||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
|
||||||
if originalType == MessageType.fileTransfer.rawValue {
|
|
||||||
// Allow headroom for TLV metadata and binary framing overhead.
|
|
||||||
return FileTransferLimits.maxFramedFileBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileTransferLimits.maxPayloadBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Narrow environment for `BLEFragmentHandler`.
|
|
||||||
///
|
|
||||||
/// All queue hops (the message-queue entry hop and the collections barrier
|
|
||||||
/// around the assembly buffer) live on the `BLEService` side — the entry hop
|
|
||||||
/// in `BLEService.handleFragment`, the barrier inside the supplied closures —
|
|
||||||
/// keeping the handler queue-agnostic and synchronously testable.
|
|
||||||
struct BLEFragmentHandlerEnvironment {
|
|
||||||
/// Local peer identity at the time the fragment is handled.
|
|
||||||
let localPeerID: () -> PeerID
|
|
||||||
/// Tracks broadcast fragments for gossip sync.
|
|
||||||
let trackPacketSeen: (BitchatPacket) -> Void
|
|
||||||
/// Appends the fragment to the assembly buffer (collections barrier write).
|
|
||||||
let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult
|
|
||||||
/// Ingress acceptance check for the reassembled inner packet.
|
|
||||||
let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool
|
|
||||||
/// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed).
|
|
||||||
let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking,
|
|
||||||
/// assembly-buffer appends, and reassembled-packet validation and re-injection
|
|
||||||
/// into the receive pipeline.
|
|
||||||
final class BLEFragmentHandler {
|
|
||||||
private let environment: BLEFragmentHandlerEnvironment
|
|
||||||
|
|
||||||
init(environment: BLEFragmentHandlerEnvironment) {
|
|
||||||
self.environment = environment
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
||||||
let env = environment
|
|
||||||
// Don't process our own fragments
|
|
||||||
if peerID == env.localPeerID() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
|
||||||
|
|
||||||
if header.isBroadcastFragment {
|
|
||||||
env.trackPacketSeen(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
let assemblyResult = env.appendFragment(header)
|
|
||||||
|
|
||||||
logFragmentAssemblyResult(assemblyResult)
|
|
||||||
|
|
||||||
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
|
|
||||||
|
|
||||||
// Decode the original packet bytes we reassembled, so flags/compression are preserved
|
|
||||||
if var originalPacket = BinaryProtocol.decode(reassembled) {
|
|
||||||
|
|
||||||
// Reassembled packet validation
|
|
||||||
let innerSender = PeerID(hexData: originalPacket.senderID)
|
|
||||||
if !env.isAcceptedIngressPayload(originalPacket, innerSender) {
|
|
||||||
// Cleanup below
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
|
||||||
originalPacket.ttl = 0
|
|
||||||
env.processReassembledPacket(originalPacket, peerID)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
|
|
||||||
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
|
|
||||||
if started {
|
|
||||||
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case let .stored(header, started):
|
|
||||||
logStartedIfNeeded(header: header, started: started)
|
|
||||||
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
|
||||||
|
|
||||||
case let .complete(header, _, started):
|
|
||||||
logStartedIfNeeded(header: header, started: started)
|
|
||||||
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
|
||||||
|
|
||||||
case let .oversized(header, projectedSize, limit, started):
|
|
||||||
logStartedIfNeeded(header: header, started: started)
|
|
||||||
SecureLogger.warning(
|
|
||||||
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
|
|
||||||
category: .security
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEInboundWriteChunk: Equatable {
|
|
||||||
let offset: Int
|
|
||||||
let data: Data
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEInboundWriteAppendMetadata: Equatable {
|
|
||||||
let accumulatedBytes: Int
|
|
||||||
let appendedBytes: Int
|
|
||||||
let offsets: [Int]
|
|
||||||
let packetType: UInt8?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEInboundWriteBuffer {
|
|
||||||
enum AppendResult {
|
|
||||||
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
|
|
||||||
case waiting(metadata: BLEInboundWriteAppendMetadata)
|
|
||||||
case oversized(metadata: BLEInboundWriteAppendMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buffersByCentralID: [String: Data] = [:]
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
buffersByCentralID.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(
|
|
||||||
chunks: [BLEInboundWriteChunk],
|
|
||||||
for centralID: String,
|
|
||||||
capBytes: Int
|
|
||||||
) -> AppendResult {
|
|
||||||
var combined = buffersByCentralID[centralID] ?? Data()
|
|
||||||
var appendedBytes = 0
|
|
||||||
var offsets: [Int] = []
|
|
||||||
|
|
||||||
for chunk in chunks where !chunk.data.isEmpty {
|
|
||||||
offsets.append(chunk.offset)
|
|
||||||
let end = chunk.offset + chunk.data.count
|
|
||||||
|
|
||||||
if combined.count < end {
|
|
||||||
combined.append(Data(repeating: 0, count: end - combined.count))
|
|
||||||
}
|
|
||||||
|
|
||||||
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
|
|
||||||
appendedBytes += chunk.data.count
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata = BLEInboundWriteAppendMetadata(
|
|
||||||
accumulatedBytes: combined.count,
|
|
||||||
appendedBytes: appendedBytes,
|
|
||||||
offsets: offsets,
|
|
||||||
packetType: combined.count >= 2 ? combined[1] : nil
|
|
||||||
)
|
|
||||||
|
|
||||||
if let packet = BinaryProtocol.decode(combined) {
|
|
||||||
buffersByCentralID.removeValue(forKey: centralID)
|
|
||||||
return .decoded(packet: packet, metadata: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard combined.count <= capBytes else {
|
|
||||||
buffersByCentralID.removeValue(forKey: centralID)
|
|
||||||
return .oversized(metadata: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
buffersByCentralID[centralID] = combined
|
|
||||||
return .waiting(metadata: metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEIncomingFileStore {
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
|
||||||
|
|
||||||
private let fileManager: FileManager
|
|
||||||
private let baseDirectory: URL?
|
|
||||||
private let dateProvider: () -> Date
|
|
||||||
|
|
||||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
|
||||||
self.fileManager = fileManager
|
|
||||||
self.baseDirectory = baseDirectory
|
|
||||||
self.dateProvider = dateProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
func save(
|
|
||||||
data: Data,
|
|
||||||
preferredName: String?,
|
|
||||||
subdirectory: String,
|
|
||||||
fallbackExtension: String?,
|
|
||||||
defaultPrefix: String
|
|
||||||
) -> URL? {
|
|
||||||
do {
|
|
||||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
|
||||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
let sanitized = sanitizedFileName(
|
|
||||||
preferredName,
|
|
||||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
|
||||||
fallbackExtension: fallbackExtension
|
|
||||||
)
|
|
||||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
|
||||||
try data.write(to: destination, options: .atomic)
|
|
||||||
return destination
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enforceQuota(reservingBytes: Int) {
|
|
||||||
do {
|
|
||||||
let base = try filesDirectory()
|
|
||||||
let incomingDirs = [
|
|
||||||
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
|
||||||
base.appendingPathComponent("images/incoming", isDirectory: true),
|
|
||||||
base.appendingPathComponent("files/incoming", isDirectory: true)
|
|
||||||
]
|
|
||||||
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
|
||||||
|
|
||||||
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
|
||||||
guard let contents = try? fileManager.contentsOfDirectory(
|
|
||||||
at: dir,
|
|
||||||
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
|
||||||
options: [.skipsHiddenFiles]
|
|
||||||
) else { continue }
|
|
||||||
|
|
||||||
for fileURL in contents {
|
|
||||||
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
|
||||||
let size = attrs.fileSize,
|
|
||||||
let modified = attrs.contentModificationDate else { continue }
|
|
||||||
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
|
||||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
|
||||||
guard currentUsage > targetUsage else { return }
|
|
||||||
|
|
||||||
let needToFree = currentUsage - targetUsage
|
|
||||||
var freedSpace: Int64 = 0
|
|
||||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
|
||||||
guard freedSpace < needToFree else { break }
|
|
||||||
do {
|
|
||||||
try fileManager.removeItem(at: file.url)
|
|
||||||
freedSpace += file.size
|
|
||||||
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if freedSpace > 0 {
|
|
||||||
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
|
||||||
let root = try baseDirectory ?? fileManager.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
|
||||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return filesDir
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
|
||||||
var candidate = (name ?? "")
|
|
||||||
.replacingOccurrences(of: "\0", with: "")
|
|
||||||
.precomposedStringWithCanonicalMapping
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "\\", with: "_")
|
|
||||||
|
|
||||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
|
||||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
|
||||||
if candidate.isEmpty { candidate = defaultName }
|
|
||||||
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
|
||||||
|
|
||||||
if candidate.count > 120 {
|
|
||||||
let ext = (candidate as NSString).pathExtension
|
|
||||||
let base = (candidate as NSString).deletingPathExtension
|
|
||||||
candidate = ext.isEmpty
|
|
||||||
? String(candidate.prefix(120))
|
|
||||||
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
|
||||||
candidate += ".\(fallbackExtension)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate.isEmpty ? defaultName : candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
|
||||||
let directoryPath = directory.standardizedFileURL.path
|
|
||||||
func isInsideDirectory(_ url: URL) -> Bool {
|
|
||||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidate = directory.appendingPathComponent(fileName)
|
|
||||||
guard isInsideDirectory(candidate) else {
|
|
||||||
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
|
||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseName = (fileName as NSString).deletingPathExtension
|
|
||||||
let ext = (fileName as NSString).pathExtension
|
|
||||||
for counter in 1..<100 {
|
|
||||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
|
||||||
candidate = directory.appendingPathComponent(newName)
|
|
||||||
guard isInsideDirectory(candidate) else {
|
|
||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
|
||||||
}
|
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func timestampString(from date: Date) -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEIngressLinkID: Hashable, Equatable {
|
|
||||||
case peripheral(String)
|
|
||||||
case central(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEIngressPacketContext: Equatable {
|
|
||||||
let receivedFromPeerID: PeerID
|
|
||||||
let validationPeerID: PeerID
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEIngressLinkRecord: Equatable {
|
|
||||||
let link: BLEIngressLinkID
|
|
||||||
let peerID: PeerID
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEIngressRejection: Error, Equatable {
|
|
||||||
case selfLoopback(packetType: UInt8)
|
|
||||||
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEIngressLinkRegistry {
|
|
||||||
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
ingressByMessageID.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
ingressByMessageID.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
|
||||||
ingressByMessageID[Self.messageID(for: packet)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
|
||||||
record(for: packet)?.link
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerID(for packet: BitchatPacket) -> PeerID? {
|
|
||||||
record(for: packet)?.peerID
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func recordIfNew(
|
|
||||||
_ packet: BitchatPacket,
|
|
||||||
link: BLEIngressLinkID,
|
|
||||||
peerID: PeerID,
|
|
||||||
now: Date = Date(),
|
|
||||||
lifetime: TimeInterval
|
|
||||||
) -> Bool {
|
|
||||||
let messageID = Self.messageID(for: packet)
|
|
||||||
if let existing = ingressByMessageID[messageID],
|
|
||||||
now.timeIntervalSince(existing.timestamp) <= lifetime {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func prune(before cutoff: Date) {
|
|
||||||
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func packetContext(
|
|
||||||
for packet: BitchatPacket,
|
|
||||||
claimedSenderID: PeerID,
|
|
||||||
boundPeerID: PeerID?,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
directAnnounceTTL: UInt8
|
|
||||||
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
|
|
||||||
if claimedSenderID == localPeerID,
|
|
||||||
!isSelfAuthoredSyncResponse(packet) {
|
|
||||||
return .failure(.selfLoopback(packetType: packet.type))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let boundPeerID,
|
|
||||||
boundPeerID != claimedSenderID,
|
|
||||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
|
||||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
|
||||||
}
|
|
||||||
|
|
||||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
|
||||||
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
|
||||||
return .success(BLEIngressPacketContext(
|
|
||||||
receivedFromPeerID: receivedFromPeerID,
|
|
||||||
validationPeerID: validationPeerID
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func messageID(for packet: BitchatPacket) -> String {
|
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
|
||||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
|
||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
|
||||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
|
||||||
packet.isRSR && packet.ttl == 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEIngressPacketGuard {
|
|
||||||
enum Rejection: Error, Equatable {
|
|
||||||
case selfLoopback(packetType: UInt8)
|
|
||||||
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
|
||||||
case invalidRSR(peerID: PeerID)
|
|
||||||
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func evaluate(
|
|
||||||
packet: BitchatPacket,
|
|
||||||
claimedSenderID: PeerID,
|
|
||||||
boundPeerID: PeerID?,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
directAnnounceTTL: UInt8,
|
|
||||||
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
maxTimestampSkewMs: UInt64 = 120_000,
|
|
||||||
isValidSyncResponse: (PeerID) -> Bool
|
|
||||||
) -> Result<BLEIngressPacketContext, Rejection> {
|
|
||||||
let contextResult = BLEIngressLinkRegistry.packetContext(
|
|
||||||
for: packet,
|
|
||||||
claimedSenderID: claimedSenderID,
|
|
||||||
boundPeerID: boundPeerID,
|
|
||||||
localPeerID: localPeerID,
|
|
||||||
directAnnounceTTL: directAnnounceTTL
|
|
||||||
)
|
|
||||||
|
|
||||||
let context: BLEIngressPacketContext
|
|
||||||
switch contextResult {
|
|
||||||
case .success(let acceptedContext):
|
|
||||||
context = acceptedContext
|
|
||||||
case .failure(.selfLoopback(let packetType)):
|
|
||||||
return .failure(.selfLoopback(packetType: packetType))
|
|
||||||
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
|
||||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch validatePayload(
|
|
||||||
packet,
|
|
||||||
from: context.validationPeerID,
|
|
||||||
nowMs: nowMs,
|
|
||||||
maxTimestampSkewMs: maxTimestampSkewMs,
|
|
||||||
isValidSyncResponse: isValidSyncResponse
|
|
||||||
) {
|
|
||||||
case .success:
|
|
||||||
return .success(context)
|
|
||||||
case .failure(let rejection):
|
|
||||||
return .failure(rejection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func validatePayload(
|
|
||||||
_ packet: BitchatPacket,
|
|
||||||
from peerID: PeerID,
|
|
||||||
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
maxTimestampSkewMs: UInt64 = 120_000,
|
|
||||||
isValidSyncResponse: (PeerID) -> Bool
|
|
||||||
) -> Result<Void, Rejection> {
|
|
||||||
if packet.isRSR {
|
|
||||||
guard isValidSyncResponse(peerID) else {
|
|
||||||
return .failure(.invalidRSR(peerID: peerID))
|
|
||||||
}
|
|
||||||
return .success(())
|
|
||||||
}
|
|
||||||
|
|
||||||
let packetTime = packet.timestamp
|
|
||||||
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
|
|
||||||
guard skew <= maxTimestampSkewMs else {
|
|
||||||
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
|
|
||||||
}
|
|
||||||
|
|
||||||
return .success(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import CoreBluetooth
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEPeripheralLinkState {
|
|
||||||
let peripheral: CBPeripheral
|
|
||||||
var characteristic: CBCharacteristic?
|
|
||||||
var peerID: PeerID?
|
|
||||||
var isConnecting: Bool
|
|
||||||
var isConnected: Bool
|
|
||||||
var lastConnectionAttempt: Date?
|
|
||||||
var assembler: NotificationStreamAssembler
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEDirectLinkState: Equatable {
|
|
||||||
let hasPeripheral: Bool
|
|
||||||
let hasCentral: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLESubscribedCentralSnapshot {
|
|
||||||
let centrals: [CBCentral]
|
|
||||||
let peerIDsByCentralUUID: [String: PeerID]
|
|
||||||
|
|
||||||
func central(for peerID: PeerID) -> CBCentral? {
|
|
||||||
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Owns all BLE link state (peripheral connections we hold as central, and
|
|
||||||
/// central subscriptions we serve as peripheral). The store has no internal
|
|
||||||
/// locking: every access must happen on the single owning queue (the BLE
|
|
||||||
/// queue). Other queues must go through BLEService's `readLinkState`, which
|
|
||||||
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
|
|
||||||
/// any access from the wrong queue.
|
|
||||||
final class BLELinkStateStore {
|
|
||||||
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
|
|
||||||
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
|
|
||||||
private(set) var subscribedCentrals: [CBCentral] = []
|
|
||||||
private(set) var centralToPeerID: [String: PeerID] = [:]
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private var ownerQueue: DispatchQueue?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Pin the store to its owning queue. Debug-only enforcement; release
|
|
||||||
/// builds are unchanged.
|
|
||||||
func assumeOwnership(of queue: DispatchQueue) {
|
|
||||||
#if DEBUG
|
|
||||||
ownerQueue = queue
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private func assertOwned() {
|
|
||||||
#if DEBUG
|
|
||||||
if let queue = ownerQueue {
|
|
||||||
dispatchPrecondition(condition: .onQueue(queue))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var peripheralStates: [BLEPeripheralLinkState] {
|
|
||||||
assertOwned()
|
|
||||||
return Array(peripherals.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
|
||||||
assertOwned()
|
|
||||||
return BLESubscribedCentralSnapshot(
|
|
||||||
centrals: subscribedCentrals,
|
|
||||||
peerIDsByCentralUUID: centralToPeerID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var subscribedCentralCount: Int {
|
|
||||||
assertOwned()
|
|
||||||
return subscribedCentrals.count
|
|
||||||
}
|
|
||||||
|
|
||||||
var connectedOrConnectingPeripheralCount: Int {
|
|
||||||
assertOwned()
|
|
||||||
return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
|
|
||||||
assertOwned()
|
|
||||||
return peripherals[peripheralID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
|
|
||||||
assertOwned()
|
|
||||||
peripherals[peripheralID] = state
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func updatePeripheral(
|
|
||||||
_ peripheralID: String,
|
|
||||||
_ update: (inout BLEPeripheralLinkState) -> Void
|
|
||||||
) -> BLEPeripheralLinkState? {
|
|
||||||
assertOwned()
|
|
||||||
guard var state = peripherals[peripheralID] else { return nil }
|
|
||||||
update(&state)
|
|
||||||
peripherals[peripheralID] = state
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
|
|
||||||
setPeripheralState(
|
|
||||||
BLEPeripheralLinkState(
|
|
||||||
peripheral: peripheral,
|
|
||||||
characteristic: nil,
|
|
||||||
peerID: nil,
|
|
||||||
isConnecting: true,
|
|
||||||
isConnected: false,
|
|
||||||
lastConnectionAttempt: date,
|
|
||||||
assembler: NotificationStreamAssembler()
|
|
||||||
),
|
|
||||||
for: peripheral.identifier.uuidString
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func markConnected(_ peripheral: CBPeripheral) {
|
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
|
||||||
if updatePeripheral(peripheralID, {
|
|
||||||
$0.isConnecting = false
|
|
||||||
$0.isConnected = true
|
|
||||||
}) == nil {
|
|
||||||
setPeripheralState(
|
|
||||||
BLEPeripheralLinkState(
|
|
||||||
peripheral: peripheral,
|
|
||||||
characteristic: nil,
|
|
||||||
peerID: nil,
|
|
||||||
isConnecting: false,
|
|
||||||
isConnected: true,
|
|
||||||
lastConnectionAttempt: nil,
|
|
||||||
assembler: NotificationStreamAssembler()
|
|
||||||
),
|
|
||||||
for: peripheralID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
|
|
||||||
updatePeripheral(peripheralID) {
|
|
||||||
$0.characteristic = characteristic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
|
||||||
assertOwned()
|
|
||||||
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
|
||||||
}
|
|
||||||
|
|
||||||
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
|
|
||||||
assertOwned()
|
|
||||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
|
||||||
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
|
|
||||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
|
||||||
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
|
|
||||||
}
|
|
||||||
|
|
||||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
|
||||||
assertOwned()
|
|
||||||
guard let peerID else { return [] }
|
|
||||||
|
|
||||||
var links: Set<BLEIngressLinkID> = []
|
|
||||||
if let peripheralUUID = peerToPeripheralUUID[peerID] {
|
|
||||||
links.insert(.peripheral(peripheralUUID))
|
|
||||||
}
|
|
||||||
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
|
||||||
links.insert(.central(centralUUID))
|
|
||||||
}
|
|
||||||
return links
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
|
||||||
assertOwned()
|
|
||||||
return peripherals[peripheralID]?.peerID
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
|
||||||
assertOwned()
|
|
||||||
return centralToPeerID[centralUUID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func addSubscribedCentral(_ central: CBCentral) {
|
|
||||||
assertOwned()
|
|
||||||
guard !subscribedCentrals.contains(central) else { return }
|
|
||||||
subscribedCentrals.append(central)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
|
|
||||||
assertOwned()
|
|
||||||
let centralUUID = central.identifier.uuidString
|
|
||||||
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
|
||||||
return centralToPeerID.removeValue(forKey: centralUUID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
|
||||||
assertOwned()
|
|
||||||
centralToPeerID[centralUUID] = peerID
|
|
||||||
}
|
|
||||||
|
|
||||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
|
||||||
assertOwned()
|
|
||||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
|
||||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
|
||||||
assertOwned()
|
|
||||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
|
||||||
if let peerID {
|
|
||||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
return peerID
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearPeripherals() -> [PeerID] {
|
|
||||||
assertOwned()
|
|
||||||
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
|
||||||
peripherals.removeAll()
|
|
||||||
peerToPeripheralUUID.removeAll()
|
|
||||||
return peerIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearCentrals() -> [PeerID] {
|
|
||||||
assertOwned()
|
|
||||||
let peerIDs = Array(centralToPeerID.values)
|
|
||||||
subscribedCentrals.removeAll()
|
|
||||||
centralToPeerID.removeAll()
|
|
||||||
return peerIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearAll() {
|
|
||||||
assertOwned()
|
|
||||||
peripherals.removeAll()
|
|
||||||
peerToPeripheralUUID.removeAll()
|
|
||||||
subscribedCentrals.removeAll()
|
|
||||||
centralToPeerID.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
final class BLELogRateLimiter {
|
|
||||||
private let defaultMinimumInterval: TimeInterval
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
|
|
||||||
private var lastLogTimeByKey: [String: Date] = [:]
|
|
||||||
|
|
||||||
init(defaultMinimumInterval: TimeInterval) {
|
|
||||||
self.defaultMinimumInterval = defaultMinimumInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldLog(
|
|
||||||
key: String,
|
|
||||||
now: Date = Date(),
|
|
||||||
minimumInterval: TimeInterval? = nil
|
|
||||||
) -> Bool {
|
|
||||||
queue.sync {
|
|
||||||
let interval = minimumInterval ?? defaultMinimumInterval
|
|
||||||
if let lastLogTime = lastLogTimeByKey[key],
|
|
||||||
now.timeIntervalSince(lastLogTime) < interval {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
lastLogTimeByKey[key] = now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAll() {
|
|
||||||
queue.sync {
|
|
||||||
lastLogTimeByKey.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEMaintenancePlan: Equatable {
|
|
||||||
let shouldSendAnnounce: Bool
|
|
||||||
let shouldEnsureAdvertising: Bool
|
|
||||||
let shouldRunCleanup: Bool
|
|
||||||
let shouldFlushDirectedSpool: Bool
|
|
||||||
let shouldResetCounter: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEMaintenancePolicy {
|
|
||||||
static func plan(
|
|
||||||
cycle: Int,
|
|
||||||
connectedCount: Int,
|
|
||||||
peerRegistryIsEmpty: Bool,
|
|
||||||
elapsedSinceLastAnnounce: TimeInterval,
|
|
||||||
hasRecentTraffic: Bool,
|
|
||||||
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
|
||||||
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
|
||||||
) -> BLEMaintenancePlan {
|
|
||||||
BLEMaintenancePlan(
|
|
||||||
shouldSendAnnounce: shouldSendAnnounce(
|
|
||||||
connectedCount: connectedCount,
|
|
||||||
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
|
|
||||||
hasRecentTraffic: hasRecentTraffic,
|
|
||||||
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
|
|
||||||
highDegreeThreshold: highDegreeThreshold
|
|
||||||
),
|
|
||||||
shouldEnsureAdvertising: peerRegistryIsEmpty,
|
|
||||||
shouldRunCleanup: cycle.isMultiple(of: 3),
|
|
||||||
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
|
|
||||||
shouldResetCounter: cycle >= 6
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func shouldSendAnnounce(
|
|
||||||
connectedCount: Int,
|
|
||||||
elapsedSinceLastAnnounce: TimeInterval,
|
|
||||||
hasRecentTraffic: Bool,
|
|
||||||
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
|
||||||
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
|
||||||
) -> Bool {
|
|
||||||
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
guard connectedCount > 0 else {
|
|
||||||
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
let highDegree = connectedCount >= highDegreeThreshold
|
|
||||||
let base = highDegree ?
|
|
||||||
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
|
|
||||||
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
|
|
||||||
let jitter = highDegree ?
|
|
||||||
TransportConfig.bleConnectedAnnounceJitterDense :
|
|
||||||
TransportConfig.bleConnectedAnnounceJitterSparse
|
|
||||||
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
|
|
||||||
|
|
||||||
return elapsedSinceLastAnnounce >= base + jitterOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Narrow environment for `BLENoisePacketHandler`.
|
|
||||||
///
|
|
||||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
|
||||||
/// and every `noiseService.*` crypto call live inside the closures supplied by
|
|
||||||
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
|
|
||||||
struct BLENoisePacketHandlerEnvironment {
|
|
||||||
/// Local peer identity at the time the packet is handled.
|
|
||||||
let localPeerID: () -> PeerID
|
|
||||||
/// Local peer ID bytes used as the sender of handshake responses.
|
|
||||||
let localPeerIDData: () -> Data
|
|
||||||
/// TTL value used for direct (non-relayed) packets.
|
|
||||||
let messageTTL: UInt8
|
|
||||||
/// Current time source.
|
|
||||||
let now: () -> Date
|
|
||||||
/// Processes an inbound handshake message, returning an optional response payload (crypto).
|
|
||||||
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
|
|
||||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
|
||||||
let hasNoiseSession: (PeerID) -> Bool
|
|
||||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
|
||||||
let initiateHandshake: (PeerID) -> Void
|
|
||||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
|
||||||
let broadcastPacket: (BitchatPacket) -> Void
|
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
|
||||||
/// Decrypts an encrypted payload from the peer (crypto).
|
|
||||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
|
||||||
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
|
||||||
let clearSession: (PeerID) -> Void
|
|
||||||
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
|
||||||
let deliverNoisePayload: (
|
|
||||||
_ peerID: PeerID,
|
|
||||||
_ type: NoisePayloadType,
|
|
||||||
_ payload: Data,
|
|
||||||
_ timestamp: Date
|
|
||||||
) -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orchestrates the Noise session domain for inbound packets: handshake
|
|
||||||
/// processing (with response), encrypted payload decryption and dispatch,
|
|
||||||
/// and session recovery on decrypt failure.
|
|
||||||
final class BLENoisePacketHandler {
|
|
||||||
private let environment: BLENoisePacketHandlerEnvironment
|
|
||||||
|
|
||||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
|
||||||
self.environment = environment
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
||||||
let env = environment
|
|
||||||
// Use NoiseEncryptionService for handshake processing
|
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
|
||||||
// Handshake is for us
|
|
||||||
do {
|
|
||||||
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
|
|
||||||
// Send response
|
|
||||||
let responsePacket = BitchatPacket(
|
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
|
||||||
senderID: env.localPeerIDData(),
|
|
||||||
recipientID: Data(hexString: peerID.id),
|
|
||||||
timestamp: UInt64(env.now().timeIntervalSince1970 * 1000),
|
|
||||||
payload: response,
|
|
||||||
signature: nil,
|
|
||||||
ttl: env.messageTTL
|
|
||||||
)
|
|
||||||
// We're on messageQueue from delegate callback
|
|
||||||
env.broadcastPacket(responsePacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
|
||||||
// which will send any pending messages at the right time
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
|
||||||
// Try initiating a new handshake
|
|
||||||
if !env.hasNoiseSession(peerID) {
|
|
||||||
env.initiateHandshake(peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
||||||
let env = environment
|
|
||||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
|
||||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if recipientID != env.localPeerID() {
|
|
||||||
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update lastSeen for the peer we received from (important for private messages)
|
|
||||||
env.updatePeerLastSeen(peerID)
|
|
||||||
|
|
||||||
do {
|
|
||||||
let decrypted = try env.decrypt(packet.payload, peerID)
|
|
||||||
guard decrypted.count > 0 else { return }
|
|
||||||
|
|
||||||
// First byte indicates the payload type
|
|
||||||
let payloadType = decrypted[0]
|
|
||||||
let payloadData = decrypted.dropFirst()
|
|
||||||
|
|
||||||
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
|
|
||||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
|
||||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
|
||||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
|
||||||
// We received an encrypted message before establishing a session with this peer.
|
|
||||||
// Trigger a handshake so future messages can be decrypted.
|
|
||||||
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
|
||||||
if !env.hasNoiseSession(peerID) {
|
|
||||||
env.initiateHandshake(peerID)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
|
||||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
|
||||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
|
||||||
env.clearSession(peerID)
|
|
||||||
env.initiateHandshake(peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLENoisePayloadFactory {
|
|
||||||
static func privateMessage(content: String, messageID: String) -> Data? {
|
|
||||||
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return typedPayload(.privateMessage, payload: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readReceipt(originalMessageID: String) -> Data {
|
|
||||||
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func delivered(messageID: String) -> Data {
|
|
||||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
|
||||||
var typed = Data([type.rawValue])
|
|
||||||
typed.append(payload)
|
|
||||||
return typed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEPendingPrivateMessage: Equatable {
|
|
||||||
let content: String
|
|
||||||
let messageID: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLENoiseSessionQueues {
|
|
||||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
|
||||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
privateMessagesByPeerID.removeAll()
|
|
||||||
typedPayloadsByPeerID.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
|
|
||||||
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
|
|
||||||
let messages = privateMessagesByPeerID[peerID] ?? []
|
|
||||||
privateMessagesByPeerID.removeValue(forKey: peerID)
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
|
|
||||||
guard !messages.isEmpty else { return }
|
|
||||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
|
||||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
|
||||||
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
|
||||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
|
||||||
return payloads
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEOutboundFragmentPlan {
|
|
||||||
let fragmentPackets: [BitchatPacket]
|
|
||||||
let fragmentVersion: UInt8
|
|
||||||
let chunkSize: Int
|
|
||||||
let spacingMs: Int
|
|
||||||
|
|
||||||
var totalFragments: Int {
|
|
||||||
fragmentPackets.count
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldPauseScanning: Bool {
|
|
||||||
totalFragments > 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEOutboundFragmentPlanner {
|
|
||||||
private static let minimumChunkSize = 64
|
|
||||||
private static let fragmentIDLength = 8
|
|
||||||
|
|
||||||
static func makePlan(
|
|
||||||
for request: BLEOutboundFragmentTransferRequest,
|
|
||||||
defaultChunkSize: Int,
|
|
||||||
bleMaxMTU: Int,
|
|
||||||
fragmentID: Data = randomFragmentID()
|
|
||||||
) -> BLEOutboundFragmentPlan? {
|
|
||||||
guard fragmentID.count == fragmentIDLength,
|
|
||||||
let fullData = request.packet.toBinaryData(padding: request.pad) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let sizing = sizingPolicy(
|
|
||||||
for: request.packet,
|
|
||||||
requestedMaxChunk: request.maxChunk,
|
|
||||||
defaultChunkSize: defaultChunkSize,
|
|
||||||
bleMaxMTU: bleMaxMTU
|
|
||||||
)
|
|
||||||
|
|
||||||
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
|
|
||||||
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !chunks.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let fragmentRecipient: Data? = {
|
|
||||||
if let directedPeer = request.directedPeer {
|
|
||||||
return Data(hexString: directedPeer.id)
|
|
||||||
}
|
|
||||||
return request.packet.recipientID
|
|
||||||
}()
|
|
||||||
|
|
||||||
let fragmentPackets = chunks.enumerated().map { index, chunk in
|
|
||||||
makeFragmentPacket(
|
|
||||||
original: request.packet,
|
|
||||||
fragmentID: fragmentID,
|
|
||||||
index: index,
|
|
||||||
total: chunks.count,
|
|
||||||
fragmentData: chunk,
|
|
||||||
fragmentRecipient: fragmentRecipient,
|
|
||||||
fragmentVersion: sizing.fragmentVersion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BLEOutboundFragmentPlan(
|
|
||||||
fragmentPackets: fragmentPackets,
|
|
||||||
fragmentVersion: sizing.fragmentVersion,
|
|
||||||
chunkSize: sizing.chunkSize,
|
|
||||||
spacingMs: spacingMs(for: request)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sizingPolicy(
|
|
||||||
for packet: BitchatPacket,
|
|
||||||
requestedMaxChunk: Int?,
|
|
||||||
defaultChunkSize: Int,
|
|
||||||
bleMaxMTU: Int
|
|
||||||
) -> (fragmentVersion: UInt8, chunkSize: Int) {
|
|
||||||
var fragmentVersion: UInt8 = 1
|
|
||||||
var calculatedChunk = defaultChunkSize
|
|
||||||
|
|
||||||
if let route = packet.route, !route.isEmpty {
|
|
||||||
fragmentVersion = 2
|
|
||||||
let routeSize = 1 + (route.count * 8)
|
|
||||||
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
|
|
||||||
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
fragmentVersion: fragmentVersion,
|
|
||||||
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeFragmentPacket(
|
|
||||||
original packet: BitchatPacket,
|
|
||||||
fragmentID: Data,
|
|
||||||
index: Int,
|
|
||||||
total: Int,
|
|
||||||
fragmentData: Data,
|
|
||||||
fragmentRecipient: Data?,
|
|
||||||
fragmentVersion: UInt8
|
|
||||||
) -> BitchatPacket {
|
|
||||||
var payload = Data()
|
|
||||||
payload.append(fragmentID)
|
|
||||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
|
||||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
|
||||||
payload.append(packet.type)
|
|
||||||
payload.append(fragmentData)
|
|
||||||
|
|
||||||
return BitchatPacket(
|
|
||||||
type: MessageType.fragment.rawValue,
|
|
||||||
senderID: packet.senderID,
|
|
||||||
recipientID: fragmentRecipient,
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
payload: payload,
|
|
||||||
signature: nil,
|
|
||||||
ttl: packet.ttl,
|
|
||||||
version: fragmentVersion,
|
|
||||||
route: packet.route,
|
|
||||||
isRSR: packet.isRSR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
|
|
||||||
if request.directedPeer != nil || request.packet.recipientID != nil {
|
|
||||||
return TransportConfig.bleFragmentSpacingDirectedMs
|
|
||||||
}
|
|
||||||
|
|
||||||
return TransportConfig.bleFragmentSpacingMs
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func randomFragmentID() -> Data {
|
|
||||||
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEOutboundFragmentTransferRequest {
|
|
||||||
let packet: BitchatPacket
|
|
||||||
let pad: Bool
|
|
||||||
let maxChunk: Int?
|
|
||||||
let directedPeer: PeerID?
|
|
||||||
let transferId: String?
|
|
||||||
|
|
||||||
var resolvedTransferId: String? {
|
|
||||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
|
||||||
return transferId ?? packet.payload.sha256Hex()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEOutboundFragmentTransferScheduler {
|
|
||||||
enum QueuePosition {
|
|
||||||
case front
|
|
||||||
case back
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SubmitResult {
|
|
||||||
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
|
||||||
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CancelResult {
|
|
||||||
case active(transferId: String, workItems: [DispatchWorkItem])
|
|
||||||
case pending(transferId: String)
|
|
||||||
case missing
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SentResult: Equatable {
|
|
||||||
case progress(sentFragments: Int, totalFragments: Int)
|
|
||||||
case complete(sentFragments: Int, totalFragments: Int)
|
|
||||||
case missing
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ActiveTransferState {
|
|
||||||
let totalFragments: Int
|
|
||||||
var sentFragments: Int
|
|
||||||
var workItems: [DispatchWorkItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
private var activeTransfers: [String: ActiveTransferState] = [:]
|
|
||||||
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
|
||||||
|
|
||||||
var activeCount: Int {
|
|
||||||
activeTransfers.count
|
|
||||||
}
|
|
||||||
|
|
||||||
var pendingCount: Int {
|
|
||||||
pendingTransfers.count
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
|
|
||||||
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
|
|
||||||
activeTransfers.removeAll()
|
|
||||||
pendingTransfers.removeAll()
|
|
||||||
return active
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func submit(
|
|
||||||
_ request: BLEOutboundFragmentTransferRequest,
|
|
||||||
maxConcurrentTransfers: Int
|
|
||||||
) -> SubmitResult {
|
|
||||||
guard let transferId = request.resolvedTransferId else {
|
|
||||||
return .start(request: request, reservedTransferId: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
|
||||||
pendingTransfers.append(request)
|
|
||||||
return .queued(request: request, transferId: transferId, position: .back)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard activeTransfers[transferId] == nil else {
|
|
||||||
pendingTransfers.insert(request, at: 0)
|
|
||||||
return .queued(request: request, transferId: transferId, position: .front)
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
|
||||||
return .start(request: request, reservedTransferId: transferId)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func activateReservedTransfer(
|
|
||||||
id transferId: String,
|
|
||||||
totalFragments: Int,
|
|
||||||
workItems: [DispatchWorkItem]
|
|
||||||
) -> Bool {
|
|
||||||
guard activeTransfers[transferId] != nil else { return false }
|
|
||||||
activeTransfers[transferId] = ActiveTransferState(
|
|
||||||
totalFragments: totalFragments,
|
|
||||||
sentFragments: 0,
|
|
||||||
workItems: workItems
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
|
|
||||||
guard var state = activeTransfers[transferId] else { return false }
|
|
||||||
state.workItems = workItems
|
|
||||||
activeTransfers[transferId] = state
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
|
|
||||||
activeTransfers.removeValue(forKey: transferId)?.workItems
|
|
||||||
}
|
|
||||||
|
|
||||||
func isActive(_ transferId: String) -> Bool {
|
|
||||||
activeTransfers[transferId] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
|
|
||||||
if let active = activeTransfers.removeValue(forKey: transferId) {
|
|
||||||
return .active(transferId: transferId, workItems: active.workItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
|
|
||||||
pendingTransfers.remove(at: pendingIndex)
|
|
||||||
return .pending(transferId: transferId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return .missing
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func markFragmentSent(transferId: String) -> SentResult {
|
|
||||||
guard var state = activeTransfers[transferId] else { return .missing }
|
|
||||||
|
|
||||||
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
|
|
||||||
let isComplete = state.sentFragments >= state.totalFragments
|
|
||||||
|
|
||||||
if isComplete {
|
|
||||||
activeTransfers.removeValue(forKey: transferId)
|
|
||||||
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTransfers[transferId] = state
|
|
||||||
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
|
|
||||||
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
|
|
||||||
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
|
|
||||||
|
|
||||||
var results: [SubmitResult] = []
|
|
||||||
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
|
|
||||||
|
|
||||||
while availableSlots > 0, !pendingTransfers.isEmpty {
|
|
||||||
let request = pendingTransfers.removeFirst()
|
|
||||||
availableSlots -= 1
|
|
||||||
|
|
||||||
guard let transferId = request.resolvedTransferId else {
|
|
||||||
results.append(.start(request: request, reservedTransferId: nil))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
|
||||||
pendingTransfers.insert(request, at: 0)
|
|
||||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
guard activeTransfers[transferId] == nil else {
|
|
||||||
blockedFront.append(request)
|
|
||||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
|
||||||
results.append(.start(request: request, reservedTransferId: transferId))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !blockedFront.isEmpty {
|
|
||||||
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEOutboundLinkPlan: Equatable {
|
|
||||||
let directedPeerHint: PeerID?
|
|
||||||
let fragmentChunkSize: Int?
|
|
||||||
let selectedLinks: BLEFanoutSelection
|
|
||||||
let shouldSpoolDirectedPacket: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEOutboundLinkPlanner {
|
|
||||||
static func plan(
|
|
||||||
packet: BitchatPacket,
|
|
||||||
dataCount: Int,
|
|
||||||
peripheralIDs: [String],
|
|
||||||
peripheralWriteLimits: [Int],
|
|
||||||
centralIDs: [String],
|
|
||||||
centralNotifyLimits: [Int],
|
|
||||||
ingressRecord: BLEIngressLinkRecord?,
|
|
||||||
excludedLinks: Set<BLEIngressLinkID>,
|
|
||||||
peripheralPeerBindings: [String: PeerID] = [:],
|
|
||||||
centralPeerBindings: [String: PeerID] = [:],
|
|
||||||
directedOnlyPeer: PeerID?
|
|
||||||
) -> BLEOutboundLinkPlan {
|
|
||||||
if let minLimit = minimumLinkLimit(
|
|
||||||
peripheralWriteLimits: peripheralWriteLimits,
|
|
||||||
centralNotifyLimits: centralNotifyLimits
|
|
||||||
), packet.type != MessageType.fragment.rawValue,
|
|
||||||
dataCount > minLimit {
|
|
||||||
return BLEOutboundLinkPlan(
|
|
||||||
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
|
|
||||||
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
|
|
||||||
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
|
|
||||||
shouldSpoolDirectedPacket: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
|
||||||
let selectedLinks = BLEFanoutSelector.selectLinks(
|
|
||||||
peripheralIDs: peripheralIDs,
|
|
||||||
centralIDs: centralIDs,
|
|
||||||
ingressLink: ingressRecord?.link,
|
|
||||||
excludedLinks: excludedLinks,
|
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
|
||||||
centralPeerBindings: centralPeerBindings,
|
|
||||||
directedPeerHint: directedPeerHint,
|
|
||||||
packetType: packet.type,
|
|
||||||
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
|
||||||
)
|
|
||||||
|
|
||||||
return BLEOutboundLinkPlan(
|
|
||||||
directedPeerHint: directedPeerHint,
|
|
||||||
fragmentChunkSize: nil,
|
|
||||||
selectedLinks: selectedLinks,
|
|
||||||
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
|
|
||||||
directedPeerHint: directedPeerHint,
|
|
||||||
selectedLinks: selectedLinks,
|
|
||||||
packetType: packet.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
|
|
||||||
if let explicitPeer { return explicitPeer }
|
|
||||||
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
|
|
||||||
return recipient
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
|
|
||||||
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
|
|
||||||
.compactMap { $0 }
|
|
||||||
.min()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func shouldSpoolDirectedPacket(
|
|
||||||
directedPeerHint: PeerID?,
|
|
||||||
selectedLinks: BLEFanoutSelection,
|
|
||||||
packetType: UInt8
|
|
||||||
) -> Bool {
|
|
||||||
guard directedPeerHint != nil,
|
|
||||||
selectedLinks.peripheralIDs.isEmpty,
|
|
||||||
selectedLinks.centralIDs.isEmpty else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return packetType == MessageType.noiseEncrypted.rawValue ||
|
|
||||||
packetType == MessageType.noiseHandshake.rawValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEPendingNotification<Target> {
|
|
||||||
let data: Data
|
|
||||||
let targets: [Target]?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEOutboundNotificationBuffer<Target> {
|
|
||||||
enum EnqueueResult {
|
|
||||||
case enqueued(count: Int)
|
|
||||||
case full(count: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var notifications: [BLEPendingNotification<Target>] = []
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
notifications.count
|
|
||||||
}
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
notifications.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
notifications.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
|
|
||||||
guard notifications.count < capCount else {
|
|
||||||
return .full(count: notifications.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.append(BLEPendingNotification(data: data, targets: targets))
|
|
||||||
return .enqueued(count: notifications.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func takeAll() -> [BLEPendingNotification<Target>] {
|
|
||||||
let pending = notifications
|
|
||||||
notifications.removeAll()
|
|
||||||
return pending
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
|
|
||||||
guard !pending.isEmpty else { return }
|
|
||||||
notifications.insert(contentsOf: pending, at: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEOutboundPacketPolicy {
|
|
||||||
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
|
|
||||||
|
|
||||||
static func messageID(for packet: BitchatPacket) -> String {
|
|
||||||
BLEIngressLinkRegistry.messageID(for: packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func padsBLEFrame(for packetType: UInt8) -> Bool {
|
|
||||||
switch MessageType(rawValue: packetType) {
|
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
|
||||||
return true
|
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
|
||||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
|
||||||
switch messageType {
|
|
||||||
case .fragment:
|
|
||||||
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
|
||||||
case .fileTransfer:
|
|
||||||
return .fileTransfer
|
|
||||||
default:
|
|
||||||
return .high
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
|
|
||||||
max(64, limit - fragmentFrameOverhead)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fragmentTotalCount(from payload: Data) -> Int {
|
|
||||||
guard payload.count >= 12 else { return Int(UInt16.max) }
|
|
||||||
let totalHigh = Int(payload[10])
|
|
||||||
let totalLow = Int(payload[11])
|
|
||||||
let total = (totalHigh << 8) | totalLow
|
|
||||||
return max(total, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEOutboundWritePriority: Comparable {
|
|
||||||
let level: Int
|
|
||||||
let suborder: Int
|
|
||||||
|
|
||||||
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
|
|
||||||
|
|
||||||
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
|
|
||||||
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
|
|
||||||
}
|
|
||||||
|
|
||||||
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
|
|
||||||
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
|
|
||||||
|
|
||||||
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
|
|
||||||
if lhs.level != rhs.level { return lhs.level < rhs.level }
|
|
||||||
return lhs.suborder < rhs.suborder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEPendingWrite {
|
|
||||||
let priority: BLEOutboundWritePriority
|
|
||||||
let data: Data
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEOutboundWriteBuffer {
|
|
||||||
enum EnqueueResult {
|
|
||||||
case enqueued(trimmedBytes: Int, remainingBytes: Int)
|
|
||||||
case oversized(bytes: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
|
|
||||||
|
|
||||||
var peripheralIDs: [String] {
|
|
||||||
Array(writesByPeripheralID.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
writesByPeripheralID.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func enqueue(
|
|
||||||
data: Data,
|
|
||||||
for peripheralID: String,
|
|
||||||
priority: BLEOutboundWritePriority,
|
|
||||||
capBytes: Int
|
|
||||||
) -> EnqueueResult {
|
|
||||||
guard data.count <= capBytes else {
|
|
||||||
return .oversized(bytes: data.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
var queue = writesByPeripheralID[peripheralID] ?? []
|
|
||||||
let item = BLEPendingWrite(priority: priority, data: data)
|
|
||||||
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
|
|
||||||
queue.insert(item, at: insertIndex)
|
|
||||||
|
|
||||||
var total = queue.reduce(0) { $0 + $1.data.count }
|
|
||||||
var trimmedBytes = 0
|
|
||||||
|
|
||||||
while total > capBytes && !queue.isEmpty {
|
|
||||||
let removed = queue.removeLast()
|
|
||||||
trimmedBytes += removed.data.count
|
|
||||||
total -= removed.data.count
|
|
||||||
}
|
|
||||||
|
|
||||||
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
|
||||||
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
|
||||||
let items = writesByPeripheralID[peripheralID] ?? []
|
|
||||||
writesByPeripheralID[peripheralID] = nil
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
|
||||||
guard !items.isEmpty else { return }
|
|
||||||
var existing = writesByPeripheralID[peripheralID] ?? []
|
|
||||||
existing.insert(contentsOf: items, at: 0)
|
|
||||||
writesByPeripheralID[peripheralID] = existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEPacketFreshnessPolicy {
|
|
||||||
static let defaultMaxAgeSeconds: TimeInterval = 900
|
|
||||||
|
|
||||||
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
|
|
||||||
guard let recipientID else { return true }
|
|
||||||
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isStale(
|
|
||||||
timestampMilliseconds: UInt64,
|
|
||||||
now: Date,
|
|
||||||
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
|
|
||||||
) -> Bool {
|
|
||||||
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
|
||||||
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
|
|
||||||
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
|
|
||||||
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
|
|
||||||
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
|
||||||
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
|
|
||||||
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEPeerEventDebouncer {
|
|
||||||
private var lastEmitByPeer: [PeerID: Date] = [:]
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
lastEmitByPeer.count
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
|
|
||||||
if let lastEmit = lastEmitByPeer[peerID],
|
|
||||||
now.timeIntervalSince(lastEmit) < minimumInterval {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
lastEmitByPeer[peerID] = now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
lastEmitByPeer.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEPeerPublishDecision: Equatable {
|
|
||||||
case publishNow
|
|
||||||
case schedule(delay: TimeInterval)
|
|
||||||
case skip
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEPeerPublishCoalescer {
|
|
||||||
private var lastPublishAt: Date
|
|
||||||
private var publishPending: Bool
|
|
||||||
private let minimumInterval: TimeInterval
|
|
||||||
|
|
||||||
init(
|
|
||||||
lastPublishAt: Date = .distantPast,
|
|
||||||
publishPending: Bool = false,
|
|
||||||
minimumInterval: TimeInterval = 0.1
|
|
||||||
) {
|
|
||||||
self.lastPublishAt = lastPublishAt
|
|
||||||
self.publishPending = publishPending
|
|
||||||
self.minimumInterval = minimumInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
|
|
||||||
let elapsed = now.timeIntervalSince(lastPublishAt)
|
|
||||||
if elapsed >= minimumInterval {
|
|
||||||
lastPublishAt = now
|
|
||||||
return .publishNow
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !publishPending else {
|
|
||||||
return .skip
|
|
||||||
}
|
|
||||||
|
|
||||||
publishPending = true
|
|
||||||
return .schedule(delay: minimumInterval - elapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func scheduledPublishFired(now: Date) {
|
|
||||||
lastPublishAt = now
|
|
||||||
publishPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEPeerInfo: Equatable {
|
|
||||||
let peerID: PeerID
|
|
||||||
var nickname: String
|
|
||||||
var isConnected: Bool
|
|
||||||
var noisePublicKey: Data?
|
|
||||||
var signingPublicKey: Data?
|
|
||||||
var isVerifiedNickname: Bool
|
|
||||||
var lastSeen: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
|
||||||
let isNewPeer: Bool
|
|
||||||
let wasDisconnected: Bool
|
|
||||||
let previousNickname: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEPeerLinkPresence: Equatable {
|
|
||||||
var hasPeripheral: Bool
|
|
||||||
var hasCentral: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLERemovedPeer: Equatable {
|
|
||||||
let peerID: PeerID
|
|
||||||
let nickname: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEPeerConnectivityChanges: Equatable {
|
|
||||||
var disconnectedPeerIDs: [PeerID] = []
|
|
||||||
var removedPeers: [BLERemovedPeer] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEPeerRegistry {
|
|
||||||
private var peers: [PeerID: BLEPeerInfo] = [:]
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
peers.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
peers.count
|
|
||||||
}
|
|
||||||
|
|
||||||
var peerIDs: [PeerID] {
|
|
||||||
Array(peers.keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
var connectedCount: Int {
|
|
||||||
peers.values.filter(\.isConnected).count
|
|
||||||
}
|
|
||||||
|
|
||||||
var connectedPeerIDs: [PeerID] {
|
|
||||||
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
var connectedRoutingData: [Data] {
|
|
||||||
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
|
|
||||||
}
|
|
||||||
|
|
||||||
var snapshotByID: [PeerID: BLEPeerInfo] {
|
|
||||||
peers
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
peers.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
|
||||||
peers[peerID]
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func upsert(_ info: BLEPeerInfo) {
|
|
||||||
peers[info.peerID] = info
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
|
|
||||||
peers.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isConnected(_ peerID: PeerID) -> Bool {
|
|
||||||
peers[peerID.toShort()]?.isConnected ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
|
||||||
let shortID = peerID.toShort()
|
|
||||||
let meshAttached = connectedCount > 0
|
|
||||||
guard let info = peers[shortID] else { return false }
|
|
||||||
if info.isConnected { return true }
|
|
||||||
guard meshAttached else { return false }
|
|
||||||
|
|
||||||
let retention: TimeInterval = info.isVerifiedNickname
|
|
||||||
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
|
||||||
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
||||||
return now.timeIntervalSince(info.lastSeen) <= retention
|
|
||||||
}
|
|
||||||
|
|
||||||
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
|
||||||
guard let peer = peers[peerID] else { return nil }
|
|
||||||
if connectedOnly && !peer.isConnected { return nil }
|
|
||||||
return peer.nickname
|
|
||||||
}
|
|
||||||
|
|
||||||
func fingerprint(for peerID: PeerID) -> String? {
|
|
||||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
|
||||||
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
|
||||||
let snapshot = Array(peers.values)
|
|
||||||
let resolvedNames = PeerDisplayNameResolver.resolve(
|
|
||||||
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
|
|
||||||
selfNickname: selfNickname
|
|
||||||
)
|
|
||||||
return snapshot.map { info in
|
|
||||||
TransportPeerSnapshot(
|
|
||||||
peerID: info.peerID,
|
|
||||||
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
|
||||||
isConnected: info.isConnected,
|
|
||||||
noisePublicKey: info.noisePublicKey,
|
|
||||||
lastSeen: info.lastSeen
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
|
||||||
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
|
||||||
let hasCollision = peers.values.contains {
|
|
||||||
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
|
||||||
} || selfNickname == info.nickname
|
|
||||||
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func markDisconnected(_ peerID: PeerID) {
|
|
||||||
guard var info = peers[peerID] else { return }
|
|
||||||
info.isConnected = false
|
|
||||||
peers[peerID] = info
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
|
||||||
guard var peer = peers[peerID] else { return }
|
|
||||||
peer.lastSeen = date
|
|
||||||
peers[peerID] = peer
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func upsertVerifiedAnnounce(
|
|
||||||
peerID: PeerID,
|
|
||||||
nickname: String,
|
|
||||||
noisePublicKey: Data,
|
|
||||||
signingPublicKey: Data?,
|
|
||||||
isConnected: Bool,
|
|
||||||
now: Date
|
|
||||||
) -> BLEPeerAnnounceUpdate {
|
|
||||||
let existing = peers[peerID]
|
|
||||||
let update = BLEPeerAnnounceUpdate(
|
|
||||||
isNewPeer: existing == nil,
|
|
||||||
wasDisconnected: existing?.isConnected == false,
|
|
||||||
previousNickname: existing?.nickname
|
|
||||||
)
|
|
||||||
|
|
||||||
peers[peerID] = BLEPeerInfo(
|
|
||||||
peerID: existing?.peerID ?? peerID,
|
|
||||||
nickname: nickname,
|
|
||||||
isConnected: isConnected,
|
|
||||||
noisePublicKey: noisePublicKey,
|
|
||||||
signingPublicKey: signingPublicKey,
|
|
||||||
isVerifiedNickname: true,
|
|
||||||
lastSeen: now
|
|
||||||
)
|
|
||||||
|
|
||||||
return update
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func reconcileConnectivity(
|
|
||||||
now: Date,
|
|
||||||
linkStates: [PeerID: BLEPeerLinkPresence]
|
|
||||||
) -> BLEPeerConnectivityChanges {
|
|
||||||
var changes = BLEPeerConnectivityChanges()
|
|
||||||
|
|
||||||
for (peerID, peer) in Array(peers) {
|
|
||||||
let age = now.timeIntervalSince(peer.lastSeen)
|
|
||||||
let retention: TimeInterval = peer.isVerifiedNickname
|
|
||||||
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
|
||||||
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
||||||
|
|
||||||
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
|
||||||
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
|
|
||||||
if !state.hasPeripheral && !state.hasCentral {
|
|
||||||
var updated = peer
|
|
||||||
updated.isConnected = false
|
|
||||||
peers[peerID] = updated
|
|
||||||
changes.disconnectedPeerIDs.append(peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !peer.isConnected && age > retention {
|
|
||||||
peers.removeValue(forKey: peerID)
|
|
||||||
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEPeerSenderDisplayName {
|
|
||||||
static func resolveKnownPeer(
|
|
||||||
peerID: PeerID,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
localNickname: String,
|
|
||||||
peers: [PeerID: BLEPeerInfo],
|
|
||||||
allowConnectedUnverified: Bool
|
|
||||||
) -> String? {
|
|
||||||
if peerID == localPeerID {
|
|
||||||
return localNickname
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let info = peers[peerID] else { return nil }
|
|
||||||
|
|
||||||
if info.isVerifiedNickname {
|
|
||||||
return collisionResolvedName(
|
|
||||||
displayName: info.nickname,
|
|
||||||
collisionNickname: info.nickname,
|
|
||||||
peerID: peerID,
|
|
||||||
localNickname: localNickname,
|
|
||||||
peers: peers
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowConnectedUnverified, info.isConnected {
|
|
||||||
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
|
|
||||||
return collisionResolvedName(
|
|
||||||
displayName: displayName,
|
|
||||||
collisionNickname: info.nickname,
|
|
||||||
peerID: peerID,
|
|
||||||
localNickname: localNickname,
|
|
||||||
peers: peers
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func anonymousNickname(for peerID: PeerID) -> String {
|
|
||||||
"anon" + String(peerID.id.prefix(4))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func collisionResolvedName(
|
|
||||||
displayName: String,
|
|
||||||
collisionNickname: String,
|
|
||||||
peerID: PeerID,
|
|
||||||
localNickname: String,
|
|
||||||
peers: [PeerID: BLEPeerInfo]
|
|
||||||
) -> String {
|
|
||||||
let hasCollision = peers.values.contains {
|
|
||||||
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
|
|
||||||
} || localNickname == collisionNickname
|
|
||||||
|
|
||||||
guard hasCollision else { return displayName }
|
|
||||||
return displayName + "#" + String(peerID.id.prefix(4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Narrow environment for `BLEPublicMessageHandler`.
|
|
||||||
///
|
|
||||||
/// All queue hops (collections registry reads, BLE-queue link-state reads,
|
|
||||||
/// main-actor UI notification) live inside the closures supplied by
|
|
||||||
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
|
|
||||||
struct BLEPublicMessageHandlerEnvironment {
|
|
||||||
/// Local peer identity at the time the message is handled.
|
|
||||||
let localPeerID: () -> PeerID
|
|
||||||
/// Local nickname used for sender resolution and collision checks.
|
|
||||||
let localNickname: () -> String
|
|
||||||
/// Current time source.
|
|
||||||
let now: () -> Date
|
|
||||||
/// Snapshot of known peers keyed by ID (registry read).
|
|
||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
|
||||||
/// Verifies a packet's signature against a known signing public key.
|
|
||||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
|
||||||
/// Tracks the broadcast message packet for gossip sync.
|
|
||||||
let trackPacketSeen: (BitchatPacket) -> Void
|
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
|
||||||
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
|
||||||
/// Resolves and consumes the original message ID for our own re-broadcast.
|
|
||||||
let takeSelfBroadcastMessageID: (BitchatPacket) -> String?
|
|
||||||
/// Delivers `.publicMessageReceived` to the UI as one main-actor hop.
|
|
||||||
let deliverPublicMessage: (
|
|
||||||
_ peerID: PeerID,
|
|
||||||
_ nickname: String,
|
|
||||||
_ content: String,
|
|
||||||
_ timestamp: Date,
|
|
||||||
_ messageID: String?
|
|
||||||
) -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orchestrates inbound public (broadcast) messages: freshness/self-echo
|
|
||||||
/// policy, sender display-name resolution, gossip tracking, payload decoding,
|
|
||||||
/// and UI delivery.
|
|
||||||
final class BLEPublicMessageHandler {
|
|
||||||
private let environment: BLEPublicMessageHandlerEnvironment
|
|
||||||
|
|
||||||
init(environment: BLEPublicMessageHandlerEnvironment) {
|
|
||||||
self.environment = environment
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
||||||
let env = environment
|
|
||||||
let now = env.now()
|
|
||||||
let messageDecision = BLEPublicMessagePolicy.evaluate(
|
|
||||||
packet: packet,
|
|
||||||
from: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
now: now
|
|
||||||
)
|
|
||||||
|
|
||||||
let messagePolicy: BLEPublicMessageAcceptance
|
|
||||||
switch messageDecision {
|
|
||||||
case .accept(let acceptance):
|
|
||||||
messagePolicy = acceptance
|
|
||||||
case .reject(.selfEcho):
|
|
||||||
return
|
|
||||||
case .reject(.staleBroadcast(let ageSeconds)):
|
|
||||||
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
|
||||||
|
|
||||||
// Public messages are always signed by their sender. `senderID` is
|
|
||||||
// attacker-controlled, so registry membership alone is NOT proof of
|
|
||||||
// identity — a peer in the registry as "verified" could be impersonated
|
|
||||||
// by anyone spoofing their senderID. Require a valid packet signature
|
|
||||||
// from the claimed sender (our own echoes are exempt; they are matched
|
|
||||||
// by self-broadcast tracking below).
|
|
||||||
//
|
|
||||||
// Verify against the signing key already in the (synchronously-updated)
|
|
||||||
// peer registry first: identity-cache persistence is asynchronous, so a
|
|
||||||
// message arriving right after a verified announce would otherwise be
|
|
||||||
// dropped because `signedSenderDisplayName` only searches the persisted
|
|
||||||
// cache. Fall back to that persisted-identity lookup for peers not (yet)
|
|
||||||
// in the registry.
|
|
||||||
let isSelf = peerID == env.localPeerID()
|
|
||||||
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
|
|
||||||
let verifiedViaRegistry = !isSelf
|
|
||||||
&& (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
|
||||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
|
||||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else {
|
|
||||||
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticity is established; prefer the registry's collision-resolved
|
|
||||||
// display name, then the signature-derived name.
|
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peersSnapshot,
|
|
||||||
allowConnectedUnverified: false
|
|
||||||
) ?? signedDisplayName else {
|
|
||||||
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if messagePolicy.shouldTrackForSync {
|
|
||||||
env.trackPacketSeen(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let content = String(data: packet.payload, encoding: .utf8) else {
|
|
||||||
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Determine if we have a direct link to the sender
|
|
||||||
let directLink = env.linkState(peerID)
|
|
||||||
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
|
|
||||||
|
|
||||||
let pathTag = hasDirectLink ? "direct" : "mesh"
|
|
||||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
|
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
|
||||||
var resolvedSelfMessageID: String? = nil
|
|
||||||
if peerID == env.localPeerID() {
|
|
||||||
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
|
|
||||||
}
|
|
||||||
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEPublicMessageAcceptance: Equatable {
|
|
||||||
let shouldTrackForSync: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEPublicMessageRejection: Equatable {
|
|
||||||
case selfEcho
|
|
||||||
case staleBroadcast(ageSeconds: Double)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEPublicMessageDecision: Equatable {
|
|
||||||
case accept(BLEPublicMessageAcceptance)
|
|
||||||
case reject(BLEPublicMessageRejection)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEPublicMessagePolicy {
|
|
||||||
static func evaluate(
|
|
||||||
packet: BitchatPacket,
|
|
||||||
from peerID: PeerID,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
now: Date
|
|
||||||
) -> BLEPublicMessageDecision {
|
|
||||||
if peerID == localPeerID && packet.ttl != 0 {
|
|
||||||
return .reject(.selfEcho)
|
|
||||||
}
|
|
||||||
|
|
||||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
|
||||||
if isBroadcast,
|
|
||||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
|
||||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
|
||||||
timestampMilliseconds: packet.timestamp,
|
|
||||||
now: now
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return .accept(BLEPublicMessageAcceptance(
|
|
||||||
shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEReceivedPacketContext: Equatable {
|
|
||||||
let senderID: PeerID
|
|
||||||
let messageID: String
|
|
||||||
let messageType: MessageType?
|
|
||||||
let shouldDeduplicate: Bool
|
|
||||||
let logsHandlingDetails: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLEReceivePipeline {
|
|
||||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
|
||||||
let senderID = PeerID(hexData: packet.senderID)
|
|
||||||
// Include a payload digest so that distinct packets sharing the same
|
|
||||||
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
|
||||||
// post-handshake flush sends queued messages, delivery and read receipts
|
|
||||||
// back-to-back within a single millisecond; without the digest every
|
|
||||||
// packet after the first would be silently dropped.
|
|
||||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
|
||||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
|
||||||
let messageType = MessageType(rawValue: packet.type)
|
|
||||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
|
||||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
|
||||||
|
|
||||||
return BLEReceivedPacketContext(
|
|
||||||
senderID: senderID,
|
|
||||||
messageID: messageID,
|
|
||||||
messageType: messageType,
|
|
||||||
shouldDeduplicate: shouldDeduplicate,
|
|
||||||
logsHandlingDetails: messageType != .announce
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
|
|
||||||
connectedPeerCount > 2
|
|
||||||
}
|
|
||||||
|
|
||||||
static func relayDecision(
|
|
||||||
for packet: BitchatPacket,
|
|
||||||
senderID: PeerID,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
degree: Int,
|
|
||||||
highDegreeThreshold: Int
|
|
||||||
) -> RelayDecision {
|
|
||||||
RelayController.decide(
|
|
||||||
ttl: packet.ttl,
|
|
||||||
senderIsSelf: senderID == localPeerID,
|
|
||||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
|
||||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
|
||||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
|
||||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
|
||||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
|
||||||
degree: degree,
|
|
||||||
highDegreeThreshold: highDegreeThreshold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLERecentTrafficTracker: Equatable {
|
|
||||||
private var packetTimestamps: [Date] = []
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
packetTimestamps.count
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
packetTimestamps.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func recordPacket(at now: Date) {
|
|
||||||
packetTimestamps.append(now)
|
|
||||||
prune(at: now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
|
||||||
let cutoff = now.addingTimeInterval(-seconds)
|
|
||||||
return packetTimestamps.contains { $0 >= cutoff }
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func prune(at now: Date) {
|
|
||||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
|
||||||
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
|
||||||
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
|
||||||
}
|
|
||||||
packetTimestamps.removeAll { $0 < cutoff }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLERouteForwardingPlan {
|
|
||||||
let shouldSuppressFloodRelay: Bool
|
|
||||||
let forwardPacket: BitchatPacket?
|
|
||||||
let nextHop: PeerID?
|
|
||||||
|
|
||||||
static let allowFloodRelay = BLERouteForwardingPlan(
|
|
||||||
shouldSuppressFloodRelay: false,
|
|
||||||
forwardPacket: nil,
|
|
||||||
nextHop: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
static let suppressFloodRelay = BLERouteForwardingPlan(
|
|
||||||
shouldSuppressFloodRelay: true,
|
|
||||||
forwardPacket: nil,
|
|
||||||
nextHop: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan {
|
|
||||||
BLERouteForwardingPlan(
|
|
||||||
shouldSuppressFloodRelay: true,
|
|
||||||
forwardPacket: packet,
|
|
||||||
nextHop: nextHop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BLERouteForwardingPolicy {
|
|
||||||
static func plan(
|
|
||||||
for packet: BitchatPacket,
|
|
||||||
localPeerID: PeerID,
|
|
||||||
localRoutingData: Data?,
|
|
||||||
routingPeer: (Data) -> PeerID?,
|
|
||||||
isPeerConnected: (PeerID) -> Bool
|
|
||||||
) -> BLERouteForwardingPlan {
|
|
||||||
if PeerID(hexData: packet.recipientID) == localPeerID {
|
|
||||||
return .suppressFloodRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let route = packet.route, !route.isEmpty else {
|
|
||||||
return .allowFloodRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
guard packet.ttl > 1 else {
|
|
||||||
return .suppressFloodRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let localRoutingData else {
|
|
||||||
return .allowFloodRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let localIndex = route.firstIndex(of: localRoutingData) else {
|
|
||||||
return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
if localIndex == route.count - 1 {
|
|
||||||
guard let destinationPeer = PeerID(hexData: packet.recipientID),
|
|
||||||
isPeerConnected(destinationPeer) else {
|
|
||||||
return .allowFloodRelay
|
|
||||||
}
|
|
||||||
return .forward(relayed(packet), to: destinationPeer)
|
|
||||||
}
|
|
||||||
|
|
||||||
return forward(
|
|
||||||
packet,
|
|
||||||
toRouteData: route[localIndex + 1],
|
|
||||||
routingPeer: routingPeer,
|
|
||||||
isPeerConnected: isPeerConnected
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func forward(
|
|
||||||
_ packet: BitchatPacket,
|
|
||||||
toRouteData routeData: Data,
|
|
||||||
routingPeer: (Data) -> PeerID?,
|
|
||||||
isPeerConnected: (PeerID) -> Bool
|
|
||||||
) -> BLERouteForwardingPlan {
|
|
||||||
guard let nextPeer = routingPeer(routeData),
|
|
||||||
isPeerConnected(nextPeer) else {
|
|
||||||
return .allowFloodRelay
|
|
||||||
}
|
|
||||||
|
|
||||||
return .forward(relayed(packet), to: nextPeer)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func relayed(_ packet: BitchatPacket) -> BitchatPacket {
|
|
||||||
var relayPacket = packet
|
|
||||||
relayPacket.ttl = packet.ttl - 1
|
|
||||||
return relayPacket
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum BLEScanDutyPlan: Equatable {
|
|
||||||
case continuous
|
|
||||||
case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BLEScanDutyPolicy {
|
|
||||||
static func plan(
|
|
||||||
dutyEnabled: Bool,
|
|
||||||
appIsActive: Bool,
|
|
||||||
connectedCount: Int,
|
|
||||||
hasRecentTraffic: Bool,
|
|
||||||
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
|
||||||
) -> BLEScanDutyPlan {
|
|
||||||
let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic
|
|
||||||
let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan
|
|
||||||
|
|
||||||
guard shouldDutyCycle else {
|
|
||||||
return .continuous
|
|
||||||
}
|
|
||||||
|
|
||||||
if connectedCount >= highDegreeThreshold {
|
|
||||||
return .dutyCycle(
|
|
||||||
onDuration: TransportConfig.bleDutyOnDurationDense,
|
|
||||||
offDuration: TransportConfig.bleDutyOffDurationDense
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return .dutyCycle(
|
|
||||||
onDuration: TransportConfig.bleDutyOnDuration,
|
|
||||||
offDuration: TransportConfig.bleDutyOffDuration
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLEScheduledRelayStore {
|
|
||||||
private var relays: [String: DispatchWorkItem] = [:]
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
relays.count
|
|
||||||
}
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
relays.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) {
|
|
||||||
relays[messageID] = workItem
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func remove(messageID: String) -> DispatchWorkItem? {
|
|
||||||
relays.removeValue(forKey: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
mutating func cancel(messageID: String) -> Bool {
|
|
||||||
guard let workItem = relays.removeValue(forKey: messageID) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
workItem.cancel()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func cancelAll() {
|
|
||||||
relays.values.forEach { $0.cancel() }
|
|
||||||
relays.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAllIfOverCapacity(_ maxCount: Int) {
|
|
||||||
if relays.count > maxCount {
|
|
||||||
relays.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct BLESelfBroadcastTracker {
|
|
||||||
private struct Entry {
|
|
||||||
let messageID: String
|
|
||||||
let sentAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var entriesByDedupID: [String: Entry] = [:]
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
entriesByDedupID.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var count: Int {
|
|
||||||
entriesByDedupID.count
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) {
|
|
||||||
entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func takeMessageID(for packet: BitchatPacket) -> String? {
|
|
||||||
entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func prune(before cutoff: Date) {
|
|
||||||
guard !entriesByDedupID.isEmpty else { return }
|
|
||||||
entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll() {
|
|
||||||
entriesByDedupID.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func dedupID(for packet: BitchatPacket) -> String {
|
|
||||||
"\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user