Compare commits

..
Author SHA1 Message Date
islam 436ab46aee Extract Noise into a dedicated module 2026-04-18 13:02:16 +01:00
266 changed files with 11815 additions and 37220 deletions
-85
View File
@@ -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
+8 -111
View File
@@ -10,9 +10,6 @@ jobs:
test:
name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
timeout-minutes: 15
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -24,124 +21,24 @@ jobs:
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
- name: Noise
path: localPackages/Noise
steps:
- name: Checkout code
uses: actions/checkout@v5
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
# the runner image. A standalone swift.org toolchain (setup-swift) broke
# whenever the image's Xcode moved ahead of it ("this SDK is not
# supported by the compiler").
- name: Note toolchain version (cache key)
id: swift-version
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Set up Swift
uses: swift-actions/setup-swift@v2
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
- name: Build tests
# Built separately so the hang watchdog below times only test
# execution: a cold-cache coverage build on a slow runner can
# legitimately take several minutes, and is already bounded by the
# 15-minute job timeout.
run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests
# Perf benchmarks are excluded here and run in their own serial step
# below: measuring while parallel test processes contend for cores
# produces noisy numbers, and the XCTest measure machinery has hung
# intermittently under parallel workers on loaded runners. Excluded
# via --skip (not just the env guard): every app run since the
# baselines landed timed out at the 15-minute job limit with the
# baseline tests dispatched into the parallel phase.
#
# The watchdog samples any still-running test processes after 5
# minutes (the suite passes in seconds when healthy; the build is
# done by this step) and kills the run, so a hang fails fast with
# stacks in the log instead of a silent timeout.
env:
BITCHAT_SKIP_PERF_BASELINES: "1"
run: |
swift test --skip-build --parallel --quiet --enable-code-coverage \
--skip PerformanceBaselineTests \
--package-path ${{ matrix.path }} &
test_pid=$!
(
sleep 300
if kill -0 "$test_pid" 2>/dev/null; then
echo "::group::Tests still running after 5 minutes — sampling before kill"
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
echo "--- sample of pid $pid ---"
sample "$pid" 5 2>/dev/null || true
done
echo "::endgroup::"
pkill -KILL -P "$test_pid" 2>/dev/null || true
kill -KILL "$test_pid" 2>/dev/null || true
fi
) &
watchdog_pid=$!
wait "$test_pid" && status=0 || status=$?
kill "$watchdog_pid" 2>/dev/null || true
exit "$status"
# Benchmarks run serially on an otherwise idle runner for stable
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
- name: Run performance benchmarks (serial)
if: matrix.name == 'app'
timeout-minutes: 6
env:
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
run: swift test --quiet --filter PerformanceBaselineTests
# Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance.
- 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
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.3
MARKETING_VERSION = 1.5.1
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+16 -42
View File
@@ -1,6 +1,6 @@
# bitchat Privacy Policy
*Last updated: June 2026*
*Last updated: January 2025*
## Our Commitment
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
## Summary
- **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
- **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
1. **Identity Keys**
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
1. **Identity Key**
- A cryptographic key generated on first launch
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Private keys never leave your device; public keys are shared when needed for messaging
- Never leaves your device
2. **Nickname**
- 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
- 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
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
## What Information is Shared
@@ -69,21 +62,13 @@ When you join a password-protected room:
- Your nickname appears in the member list
- 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
bitchat **never**:
- Collects personal information
- Sells or shares your exact GPS location
- Stores data on servers we operate
- Sells your data to advertisers or data brokers
- Tracks your location
- Stores data on servers
- Shares data with third parties
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
@@ -99,27 +84,19 @@ All private messages use end-to-end encryption:
## Your Rights
You have complete control:
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: No account record exists for you to delete from us
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
- **Leave Anytime**: Close the app and your presence disappears
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
## Bluetooth & Permissions
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- No location data is accessed or stored
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
## 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
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)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No accounts or company servers
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
- No data transmitted to servers (there are none)
- Open source code for public audit
- Regular security updates
- 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:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can make us collect data already held only in your app
- No retroactive changes can affect data (since we don't collect any)
## Contact
@@ -158,7 +132,7 @@ bitchat is an open source project. For privacy questions:
## 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.
---
+4 -9
View File
@@ -17,6 +17,7 @@ let package = Package(
],
dependencies:[
.package(path: "localPackages/Arti"),
.package(path: "localPackages/Noise"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
@@ -28,6 +29,7 @@ let package = Package(
.product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "Noise", package: "Noise"),
.product(name: "Tor", package: "Arti")
],
path: "bitchat",
@@ -53,17 +55,10 @@ let package = Package(
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md",
// CI perf gate data (read by scripts/check-perf-floors.sh),
// not a test resource.
"Performance/perf-floors.json"
"README.md"
],
resources: [
.process("Localization"),
// Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources
// and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json")
.process("Localization")
]
)
]
+35 -11
View File
@@ -10,6 +10,8 @@
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 */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
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 */; };
@@ -156,6 +158,7 @@
isa = PBXFrameworksBuildPhase;
files = (
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
@@ -165,6 +168,7 @@
isa = PBXFrameworksBuildPhase;
files = (
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
@@ -228,6 +232,7 @@
A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
A63163B72F80CB2D00B8B128 /* Noise */,
);
productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -309,6 +314,7 @@
A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
A63163B52F80CB2500B8B128 /* Noise */,
);
productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -321,7 +327,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2650;
LastUpgradeCheck = 1640;
};
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
developmentRegion = en;
@@ -351,6 +357,7 @@
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
);
preferredProjectObjectVersion = 90;
projectDirPath = "";
@@ -446,6 +453,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
@@ -470,6 +478,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
@@ -496,6 +505,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -520,6 +530,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -552,6 +563,7 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -561,7 +573,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.3;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -585,6 +597,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -611,6 +624,7 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -620,7 +634,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.3;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -646,6 +660,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -655,7 +670,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -668,7 +683,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -702,7 +716,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -720,7 +733,6 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -740,6 +752,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -749,7 +762,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3;
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -762,7 +775,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -796,7 +808,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -821,7 +832,6 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -838,6 +848,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -912,6 +923,10 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Noise;
};
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
@@ -943,6 +958,15 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K;
};
A63163B52F80CB2500B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
productName = Noise;
};
A63163B72F80CB2D00B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
productName = Noise;
};
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
-102
View File
@@ -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())
}
}
}
-100
View File
@@ -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
}
}
-388
View File
@@ -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)
}
}
}
-918
View File
@@ -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
/// ephemeralstable 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
}
}
-187
View File
@@ -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
}
}
}
-176
View File
@@ -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
}
}
}
-51
View File
@@ -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()
}
}
-125
View File
@@ -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()
}
}
-260
View File
@@ -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()
}
}
-323
View File
@@ -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
}
}
-77
View File
@@ -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()
}
}
}
-152
View File
@@ -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")
}
}
+200 -44
View File
@@ -6,57 +6,128 @@
// For more information, see <https://unlicense.org>
//
import Tor
import SwiftUI
import BitFoundation
import UserNotifications
@main
struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var runtime: AppRuntime
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
@StateObject private var chatViewModel: ChatViewModel
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// 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)
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
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
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
.environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
.environmentObject(runtime.verificationModel)
.environmentObject(runtime.conversationUIModel)
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(chatViewModel)
.onAppear {
appDelegate.runtime = runtime
runtime.start()
NotificationDelegate.shared.chatViewModel = chatViewModel
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
appDelegate.chatViewModel = chatViewModel
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Start presence service (will wait for Tor readiness)
GeohashPresenceService.shared.start()
// Check for shared content
checkForSharedContent()
}
.onOpenURL { url in
runtime.handleOpenURL(url)
handleURL(url)
}
#if os(iOS)
.onChange(of: scenePhase) { newPhase in
runtime.handleScenePhaseChange(newPhase)
switch newPhase {
case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically
// 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
runtime.handleDidBecomeActiveNotification()
// Check for shared content when app becomes active
checkForSharedContent()
}
#elseif os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
runtime.handleMacDidBecomeActiveNotification()
// App became active
}
#endif
}
@@ -65,18 +136,66 @@ struct BitchatApp: App {
.windowResizability(.contentSize)
#endif
}
private func handleURL(_ url: URL) {
if url.scheme == "bitchat" && url.host == "share" {
// Handle shared content
checkForSharedContent()
}
}
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: 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)
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
true
return true
}
func applicationWillTerminate(_ application: UIApplication) {
runtime?.applicationWillTerminate()
chatViewModel?.applicationWillTerminate()
}
}
#endif
@@ -85,42 +204,79 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
import AppKit
final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) {
runtime?.applicationWillTerminate()
chatViewModel?.applicationWillTerminate()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
return true
}
}
#endif
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let userInfo = response.notification.request.content.userInfo
Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: PeerID(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()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo
Task {
let options = await self.runtime?.presentationOptions(
forNotificationIdentifier: identifier,
userInfo: userInfo
) ?? [.banner, .sound]
completionHandler(options)
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
// Access main-actor-isolated property via Task
Task { @MainActor in
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
completionHandler([])
} else {
completionHandler([.banner, .sound])
}
}
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])
}
}
+19 -33
View File
@@ -15,39 +15,30 @@ enum ImageUtilsError: Error {
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
try validateImageSource(at: url)
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> 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)
#if os(iOS)
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
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
}
static func validateImageSource(at url: URL) throws {
// Security H1: Check file size BEFORE reading into memory.
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int,
fileSize > 0,
fileSize <= maxSourceImageBytes else {
throw ImageUtilsError.invalidImage
}
let options = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
CGImageSourceGetType(source) != nil else {
throw ImageUtilsError.invalidImage
}
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
@@ -73,7 +64,7 @@ enum ImageUtils {
}
}
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
@@ -115,7 +106,7 @@ enum ImageUtils {
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
@@ -151,7 +142,7 @@ enum ImageUtils {
}
}
}
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
@@ -195,17 +186,12 @@ enum ImageUtils {
}
#endif
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
private static func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
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
if let outputDirectory {
directory = outputDirectory
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent(fileName)
}
@@ -151,68 +151,38 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// keeps the dispatch machinery alive and prevents the unit-test process from
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// no run loop, so saves never actually fired.)
// Debouncing for keychain saves
private var saveTimer: Timer?
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
private var pendingSave = false
// Encryption key
private let encryptionKey: SymmetricKey
/// True when `encryptionKey` is a throwaway generated this session because the
/// persisted key could not be read (device locked / access denied). In that
/// state we must NOT persist (it would overwrite the real cache with data the
/// next launch can't decrypt) and must NOT delete the existing cache.
private let encryptionKeyIsEphemeral: Bool
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Retrieve (or, only on genuine first run, generate) the cache
// encryption key. We MUST distinguish "key doesn't exist yet" from a
// transient failure (device locked / access denied): the legacy
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
// new key deletes the existing one first permanently orphaning the
// encrypted cache on a launch that merely couldn't read the key.
// Generate or retrieve encryption key from keychain
let loadedKey: SymmetricKey
let keyIsEphemeral: Bool
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
case .success(let keyData):
// Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
loadedKey = SymmetricKey(data: keyData)
keyIsEphemeral = false
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
case .itemNotFound:
// Genuine first run: generate and persist a new key.
let newKey = SymmetricKey(size: .bits256)
let keyData = newKey.withUnsafeBytes { Data($0) }
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
loadedKey = newKey
// If even the save failed, treat the key as ephemeral so we don't
// later try to persist a cache the next launch can't read.
keyIsEphemeral = !saved
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
// Transient/critical read failure. Do NOT overwrite the persisted
// key. Use a session-only ephemeral key; the real key and cache are
// left intact for a healthy launch.
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
}
// Generate new key if needed
else {
loadedKey = SymmetricKey(size: .bits256)
keyIsEphemeral = true
let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
}
self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral
// Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
if !keyIsEphemeral {
loadIdentityCache()
}
// Load identity cache on init
loadIdentityCache()
}
deinit {
@@ -232,37 +202,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch {
cache = IdentityCache()
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
SecureLogger.warning(
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
category: .security
)
// Log error but continue with empty cache
SecureLogger.error(error, context: "Failed to load identity cache", 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() {
// Mark that we need to save
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() {
guard pendingSave else { return }
pendingSave = false
// Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else {
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
return
}
do {
let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
@@ -274,14 +235,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
}
}
// Force immediate save (for app termination / lifecycle events). Mutations
// already persist synchronously via saveIdentityCache, so this is normally a
// no-op (performSave early-returns when nothing is pending). Runs directly on
// the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// reachable from `deinit` and from async tests on the swift-concurrency
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
// Force immediate save (for app termination)
func forceSave() {
saveTimer?.invalidate()
performSave()
}
+1 -538
View File
@@ -540,543 +540,6 @@
}
}
},
"app_info.appearance.liquid_glass" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "زجاج سائل"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "লিকুইড গ্লাস"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "זכוכית נוזלית"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "लिक्विड ग्लास"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "リキッドガラス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리퀴드 글래스"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "लिक्विड ग्लास"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "жидкое стекло"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "லிக்விட் கிளாஸ்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลิควิดกลาส"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "рідке скло"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "لیکویڈ گلاس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "液态玻璃"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "液態玻璃"
}
}
}
},
"app_info.appearance.matrix" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ماتريكس"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "ম্যাট্রিক্স"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מטריקס"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "मैट्रिक्स"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マトリックス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "매트릭스"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "म्याट्रिक्स"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "матрица"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "மேட்ரிக்ஸ்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เมทริกซ์"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "матриця"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "میٹرکس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "矩阵"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "矩陣"
}
}
}
},
"app_info.appearance.title" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "المظهر"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "চেহারা"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "ERSCHEINUNGSBILD"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "APPEARANCE"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARIENCIA"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "HITSURA"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "APPARENCE"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מראה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "दिखावट"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "TAMPILAN"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "ASPETTO"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "外観"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "화면 모드"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "PENAMPILAN"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "रूप"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "WEERGAVE"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "WYGLĄD"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARÊNCIA"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARÊNCIA"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "ОФОРМЛЕНИЕ"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "UTSEENDE"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "தோற்றம்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลักษณะที่ปรากฏ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "GÖRÜNÜM"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "ОФОРМЛЕННЯ"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "ظاہری شکل"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "GIAO DIỆN"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "外观"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "外觀"
}
}
}
},
"app_info.close" : {
"extractionState" : "manual",
"localizations" : {
@@ -24934,7 +24397,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor."
"value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor."
}
},
"es" : {
+1 -1
View File
@@ -96,7 +96,7 @@ struct RequestSyncPacket {
}
}
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)
}
}
-22
View File
@@ -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)
}
}
+30 -15
View File
@@ -7,11 +7,6 @@ import UIKit
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.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
@@ -170,16 +165,33 @@ final class GeoRelayDirectory {
}
/// 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] {
guard !entries.isEmpty, count > 0 else { return [] }
return entries
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
.prefix(count)
.map { "wss://\($0.entry.host)" }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.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
@@ -277,8 +289,6 @@ final class GeoRelayDirectory {
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)
}
@MainActor
@@ -371,7 +381,12 @@ final class GeoRelayDirectory {
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { $0.trimmed }
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 }
result.insert(Entry(host: host, lat: lat, lon: lon))
}
-7
View File
@@ -82,13 +82,6 @@ final class NostrIdentityBridge {
}
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)
+34 -80
View File
@@ -39,23 +39,22 @@ struct NostrProtocol {
content: content
)
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
let senderKey = try senderIdentity.schnorrSigningKey()
// 2. Create ephemeral key for this message
let ephemeralKey = try P256K.Schnorr.PrivateKey()
// Created ephemeral key for seal
// 3. Seal the rumor (encrypt to recipient)
let sealedEvent = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: ephemeralKey
)
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally).
// 4. Gift wrap the sealed event (encrypt to recipient again)
let giftWrap = try createGiftWrap(
seal: sealedEvent,
recipientPubkey: recipientPubkey
recipientPubkey: recipientPubkey,
senderKey: ephemeralKey
)
// Created gift wrap
@@ -85,15 +84,7 @@ struct NostrProtocol {
throw error
}
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
// 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
// 2. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
@@ -105,63 +96,10 @@ struct NostrProtocol {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
// 4. The sender claimed inside the rumor must match the key that actually
// 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)
return (content: rumor.content, senderPubkey: rumor.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)
static func createEphemeralGeohashEvent(
content: String,
@@ -257,9 +195,10 @@ struct NostrProtocol {
private static func createGiftWrap(
seal: NostrEvent,
recipientPubkey: String
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
@@ -364,7 +303,7 @@ struct NostrProtocol {
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return "v2:" + base64URLEncode(combined)
}
private static func decrypt(
@@ -375,7 +314,7 @@ struct NostrProtocol {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
throw NostrError.invalidCiphertext
@@ -641,9 +580,24 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
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 {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
File diff suppressed because it is too large Load Diff
-51
View File
@@ -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
}
}
@@ -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
}
}
-211
View File
@@ -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)"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
import Foundation
enum BLESubscriptionAnnounceDecision: Equatable {
case allowed
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
}
struct BLESubscriptionAnnounceLimiter {
private struct State {
var lastAnnounceTime: Date
var attemptCount: Int
var currentBackoffSeconds: TimeInterval
}
private var states: [String: State] = [:]
var trackedCentralCount: Int {
states.count
}
mutating func removeAll() {
states.removeAll()
}
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
pruneStaleEntries(now: now)
guard let existing = states[centralID] else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let newAttemptCount = existing.attemptCount + 1
let newBackoff = min(
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
)
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: newAttemptCount,
currentBackoffSeconds: newBackoff
)
return .rateLimited(
backoffSeconds: existing.currentBackoffSeconds,
attemptCount: existing.attemptCount,
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
)
}
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: 1,
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
)
}
private mutating func pruneStaleEntries(now: Date) {
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
states = states.filter { _, state in
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
}
}
}
+4 -6
View File
@@ -28,9 +28,9 @@ struct CommandGeoParticipant {
protocol CommandContextProvider: AnyObject {
// MARK: - State Properties
var nickname: String { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
@@ -42,8 +42,6 @@ protocol CommandContextProvider: AnyObject {
func startPrivateChat(with peerID: PeerID)
func sendPrivateMessage(_ content: String, to peerID: PeerID)
func clearCurrentPublicTimeline()
/// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String)
// MARK: - System Messages
@@ -77,7 +75,7 @@ final class CommandProcessor {
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch contextProvider?.activeChannel ?? .mesh {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
@@ -137,7 +135,7 @@ final class CommandProcessor {
private func handleWho() -> CommandResult {
// Show geohash participants when in a geohash channel; otherwise mesh peers
switch contextProvider?.activeChannel ?? .mesh {
switch LocationChannelManager.shared.selectedChannel {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = contextProvider else { return .success(message: "nobody around") }
@@ -161,7 +159,7 @@ final class CommandProcessor {
private func handleClear() -> CommandResult {
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.clearPrivateChat(peerID)
contextProvider?.privateChats[peerID]?.removeAll()
} else {
contextProvider?.clearCurrentPublicTimeline()
}
@@ -34,23 +34,7 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
/// Default keychain for the `shared` singleton. Under test this is an
/// in-memory keychain so touching `shared` never blocks on securityd
/// (`SecItemCopyMatching` can hang in test environments) and never reads
/// or writes the developer's real keychain. Production behavior is
/// unchanged. Tests that need their own instance keep injecting a mock
/// via `init(keychain:)`.
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
// PreviewKeychainManager lives in _PreviewHelpers, a development
// asset excluded from archive builds release code must not
// reference it. Tests always run Debug, so the guard is lossless.
#if DEBUG
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
#endif
return KeychainManager()
}
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain
loadFavorites()
+2 -19
View File
@@ -1,5 +1,4 @@
import BitLogger
import Combine
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
@@ -15,9 +14,7 @@ struct LocationNotesDependencies {
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
// Fires when the geo relay directory refreshes; used to retry after "no relays".
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies(
@@ -42,11 +39,7 @@ struct LocationNotesDependencies {
deriveIdentity: { geohash in
try idBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() },
relayDirectoryUpdates: NotificationCenter.default
.publisher(for: .geoRelayDirectoryDidRefresh)
.map { _ in () }
.eraseToAnyPublisher()
now: { Date() }
)
}
@@ -84,7 +77,6 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
@@ -109,15 +101,6 @@ final class LocationNotesManager: ObservableObject {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
subscribe()
// The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays".
directoryUpdateCancellable = dependencies.relayDirectoryUpdates
.sink { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.state == .noRelays else { return }
self.subscribe()
}
}
}
func setGeohash(_ newGeohash: String) {
@@ -594,22 +594,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
}
/// Removes all persisted location state and resets the in-memory view.
/// Used by the panic wipe selected channel, teleport set and bookmarks
/// (which reveal where the user has been) must not survive on device.
func panicWipe() {
storage.removeObject(forKey: selectedChannelKey)
storage.removeObject(forKey: teleportedStoreKey)
storage.removeObject(forKey: bookmarksKey)
storage.removeObject(forKey: bookmarkNamesKey)
teleportedSet.removeAll()
bookmarkMembership.removeAll()
bookmarks = []
bookmarkNames = [:]
teleported = false
selectedChannel = .mesh
}
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
+19 -83
View File
@@ -6,13 +6,6 @@ import Foundation
@MainActor
final class MessageRouter {
private let transports: [Transport]
private let now: () -> Date
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
/// so the UI can surface the failure instead of leaving the message in a
/// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
@@ -20,7 +13,6 @@ final class MessageRouter {
let nickname: String
let messageID: String
let timestamp: Date
var sendAttempts: Int = 0
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -28,13 +20,9 @@ final class MessageRouter {
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
// Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
init(transports: [Transport]) {
self.transports = transports
self.now = now
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -73,54 +61,26 @@ final class MessageRouter {
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = connectedTransport(for: peerID) {
// A live link is a strong delivery signal; trust it outright.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
return
}
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic (e.g.
// the mesh retention window), so the send can silently go nowhere.
// Send now, but retain a copy until a delivery/read ack clears it;
// receivers dedup resends by message ID.
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID)
} else {
var unsent = message
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
// Queue for later with timestamp for TTL tracking
if outbox[peerID] == nil { outbox[peerID] = [] }
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", category: .session)
}
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
}
}
/// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) {
for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered
}
}
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
var queue = outbox[peerID] ?? []
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh)
queue.removeAll { $0.messageID == message.messageID }
queue.append(message)
// Enforce per-peer size limit with FIFO eviction
if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session)
onMessageDropped?(evicted.messageID, peerID)
}
outbox[peerID] = queue
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
@@ -151,34 +111,19 @@ final class MessageRouter {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
let now = now()
let now = Date()
var remaining: [QueuedMessage] = []
for message in queued {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
onMessageDropped?(message.messageID, peerID)
continue
}
if let transport = connectedTransport(for: peerID) {
// Live link: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
} else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
onMessageDropped?(message.messageID, peerID)
continue
}
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
var retained = message
retained.sendAttempts += 1
remaining.append(retained)
} else {
remaining.append(message)
}
@@ -197,21 +142,12 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = now()
let now = Date()
for peerID in Array(outbox.keys) {
var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in
guard now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds else { return false }
expiredMessageIDs.append(message.messageID)
return true
}
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
onMessageDropped?(messageID, peerID)
}
}
}
}
@@ -44,11 +44,7 @@ final class NetworkActivationService: ObservableObject {
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
// (via its live dependencies), so capturing NostrRelayManager.shared here would
// re-enter whichever singleton's dispatch_once started first and trap at launch.
private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider()
private let relayControllerProvider: () -> NetworkActivationRelayControlling
private let relayController: NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
@@ -59,7 +55,7 @@ final class NetworkActivationService: ObservableObject {
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayControllerProvider = { NostrRelayManager.shared }
relayController = NostrRelayManager.shared
proxyController = TorURLSession.shared
notificationCenter = .default
}
@@ -81,7 +77,7 @@ final class NetworkActivationService: ObservableObject {
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayControllerProvider = { relayController }
self.relayController = relayController
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
@@ -84,6 +84,7 @@
import BitLogger
import BitFoundation
import Noise
import Foundation
import CryptoKit
+14 -7
View File
@@ -106,7 +106,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
// MARK: - Transport Protocol Conformance
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
@@ -142,9 +141,17 @@ final class NostrTransport: Transport, @unchecked Sendable {
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
// Nostr does not use Noise sessions here; the inert Transport defaults
// for the noise* identity hooks apply.
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService?
func getNoiseService() -> NoiseEncryptionService {
if let noiseService = Self.cachedNoiseService {
return noiseService
}
let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService
return noiseService
}
// Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
@@ -166,9 +173,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits
// Use barrier to synchronize access to readQueue
queue.async(flags: .barrier) {
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
self.processReadQueueIfNeeded()
queue.async(flags: .barrier) { [weak self] in
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
self?.processReadQueueIfNeeded()
}
}
@@ -21,19 +21,6 @@ struct NotificationStreamAssembler {
pendingFrameExpectedLength = 0
}
private mutating func discardLeadingPaddingIfPresent() -> Bool {
guard let first = buffer.first else { return false }
guard first != 1 && first != 2 else { return false }
let paddingLength = Int(first)
guard paddingLength > 0, paddingLength <= buffer.count else { return false }
guard buffer.prefix(paddingLength).allSatisfy({ $0 == first }) else { return false }
buffer.removeFirst(paddingLength)
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
return true
}
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
@@ -55,9 +42,6 @@ struct NotificationStreamAssembler {
while buffer.count >= minimumFramePrefix {
guard let version = buffer.first else { break }
guard version == 1 || version == 2 else {
if discardLeadingPaddingIfPresent() {
continue
}
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
@@ -149,11 +133,6 @@ struct NotificationStreamAssembler {
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
_ = discardLeadingPaddingIfPresent()
}
if discardLeadingPaddingIfPresent() {
return (frames, dropped, didReset)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
+123 -137
View File
@@ -8,25 +8,14 @@
import BitLogger
import BitFoundation
import Combine
import Foundation
import SwiftUI
/// Manages private chat session policy (selection, read receipts,
/// consolidation). Message storage lives in the single-writer
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
/// `privateChats` / `unreadMessages` properties below are read-only views
/// derived from it.
@MainActor
/// Manages all private chat functionality
final class PrivateChatManager: ObservableObject {
/// Read-only mirror of `ConversationStore.selectedPrivatePeerID` the
/// store is the sole owner of conversation selection. Kept `@Published`
/// so existing observers (`objectWillChange` forwarding into
/// `ChatViewModel`) keep firing on selection changes. Mutate via
/// `startChat(with:)` / `endChat()`, which route through the store's
/// `setSelectedPrivatePeer` intent.
@Published private(set) var selectedPeer: PeerID? = nil
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -36,51 +25,13 @@ final class PrivateChatManager: ObservableObject {
weak var messageRouter: MessageRouter?
// Peer service for looking up peer info during consolidation
weak var unifiedPeerService: UnifiedPeerService?
/// Single source of truth for message and selection state; injected by
/// the bootstrapper (`wireServiceGraph`).
var conversationStore: ConversationStore? {
didSet { bindSelectionMirror() }
}
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
init(meshService: Transport? = nil) {
self.meshService = meshService
self.conversationStore = conversationStore
bindSelectionMirror() // didSet does not fire during init
}
/// Keeps `selectedPeer` in lock-step with the store's selection axis
/// (including store-internal handoffs such as conversation migration).
private func bindSelectionMirror() {
guard let store = conversationStore else {
selectedPeerMirrorCancellable = nil
return
}
selectedPeerMirrorCancellable = store.$selectedPrivatePeerID
.sink { [weak self] peerID in
guard let self, self.selectedPeer != peerID else { return }
self.selectedPeer = peerID
}
}
// MARK: - Derived message state (read-only compat views)
/// All private chats keyed by routing peer ID, derived from the store.
/// Mutations go through the store's intent API only.
@MainActor
var privateChats: [PeerID: [BitchatMessage]] {
conversationStore?.directMessagesByRoutingPeerID() ?? [:]
}
/// Unread chats, derived from the store's unread state.
@MainActor
var unreadMessages: Set<PeerID> {
conversationStore?.unreadDirectRoutingPeerIDs() ?? []
}
@MainActor
private func messages(for peerID: PeerID) -> [BitchatMessage] {
conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
// Cap for messages stored per private chat
private let privateChatCap = TransportConfig.privateChatCap
// MARK: - Message Consolidation
@@ -93,51 +44,57 @@ final class PrivateChatManager: ObservableObject {
/// - Returns: True if any unread messages were found during consolidation
@MainActor
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
guard let meshService = meshService, let store = conversationStore else { return false }
guard let meshService = meshService else { return false }
var hasUnreadMessages = false
// 1. Consolidate from stable Noise key (64-char hex)
if let peer = unifiedPeerService?.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let nostrMessages = messages(for: noiseKeyHex)
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
for message in nostrMessages {
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
// Store append dedups by message ID (skips ones the
// target chat already has).
guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue }
if !existingMessageIds.contains(message.id) {
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
}
}
}
}
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
if hasUnreadMessages {
store.markUnread(.directPeer(peerID))
} else {
store.markRead(.directPeer(noiseKeyHex))
unreadMessages.insert(peerID)
} else if unreadMessages.contains(noiseKeyHex) {
unreadMessages.remove(noiseKeyHex)
}
store.removeConversation(.directPeer(noiseKeyHex))
privateChats.removeValue(forKey: noiseKeyHex)
}
}
@@ -155,43 +112,52 @@ final class PrivateChatManager: ObservableObject {
}
if !tempPeerIDsToConsolidate.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
var consolidatedCount = 0
var hadUnreadTemp = false
let unreadPeerIDs = unreadMessages
for tempPeerID in tempPeerIDsToConsolidate {
if unreadPeerIDs.contains(tempPeerID) {
if unreadMessages.contains(tempPeerID) {
hadUnreadTemp = true
}
for message in messages(for: tempPeerID) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
if store.append(updatedMessage, to: .directPeer(peerID)) {
consolidatedCount += 1
if let tempMessages = privateChats[tempPeerID] {
for message in tempMessages {
if !existingMessageIds.contains(message.id) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
consolidatedCount += 1
}
}
privateChats.removeValue(forKey: tempPeerID)
unreadMessages.remove(tempPeerID)
}
store.removeConversation(.directPeer(tempPeerID))
}
if hadUnreadTemp {
store.markUnread(.directPeer(peerID))
unreadMessages.insert(peerID)
hasUnreadMessages = true
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
}
if consolidatedCount > 0 {
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
}
}
@@ -202,7 +168,9 @@ final class PrivateChatManager: ObservableObject {
/// Syncs the read receipt tracking between manager and view model for sent messages
@MainActor
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
for message in messages(for: peerID) {
guard let messages = privateChats[peerID] else { return }
for message in messages {
if message.sender == nickname {
if let status = message.deliveryStatus {
switch status {
@@ -216,68 +184,86 @@ final class PrivateChatManager: ObservableObject {
}
}
}
/// Start a private chat with a peer. Selection is mutated through the
/// store's intent (the store owns it); the manager keeps its side
/// effects (fingerprint tracking, read receipts, unread clearing).
@MainActor
/// Start a private chat with a peer
func startChat(with peerID: PeerID) {
// Also creates the conversation if needed and updates the derived
// `selectedConversationID`; `selectedPeer` mirrors the change.
conversationStore?.setSelectedPrivatePeer(peerID)
selectedPeer = peerID
// Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint
}
// Mark messages as read
markAsRead(from: peerID)
// Initialize chat if needed
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
}
/// End the current private chat (selection returns to the active public
/// channel's conversation).
/// End the current private chat
func endChat() {
conversationStore?.setSelectedPrivatePeer(nil)
selectedPeer = nil
selectedPeerFingerprint = nil
}
/// No-op since the `ConversationStore` cutover: the store maintains
/// chronological order and dedups by message ID on every insert, so the
/// per-append re-sort/dedup sweep this performed is no longer needed.
/// Kept only for API compatibility until step 5 removes the callers.
func sanitizeChat(for peerID: PeerID) {}
/// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: PeerID) {
guard let arr = privateChats[peerID] else { return }
if arr.count <= 1 {
return
}
var indexByID: [String: Int] = [:]
indexByID.reserveCapacity(arr.count)
var deduped: [BitchatMessage] = []
deduped.reserveCapacity(arr.count)
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if let existing = indexByID[msg.id] {
deduped[existing] = msg
} else {
indexByID[msg.id] = deduped.count
deduped.append(msg)
}
}
privateChats[peerID] = deduped
}
/// Mark messages from a peer as read
@MainActor
func markAsRead(from peerID: PeerID) {
conversationStore?.markRead(.directPeer(peerID))
unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet
for message in messages(for: peerID) {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
}
}
// MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
return
}
sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService?.myPeerID ?? PeerID(str: ""),
readerNickname: meshService?.myNickname ?? ""
)
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
+2 -13
View File
@@ -11,7 +11,6 @@ struct RelayDecision {
struct RelayController {
static func decide(ttl: UInt8,
senderIsSelf: Bool,
recipientIsSelf: Bool = false,
isEncrypted: Bool,
isDirectedEncrypted: Bool,
isFragment: Bool,
@@ -23,7 +22,7 @@ struct RelayController {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
// Suppress obvious non-relays
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
if ttlCap <= 1 || senderIsSelf {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
}
@@ -39,12 +38,7 @@ struct RelayController {
}
if isFragment {
// Dense graphs clamp harder to contain full-fanout fragment floods;
// sparse graphs get full depth so media reaches as far as text.
let fragmentCap = degree >= highDegreeThreshold
? TransportConfig.bleFragmentRelayTtlCapDense
: TransportConfig.bleFragmentRelayTtlCap
let ttlLimit = min(ttlCap, fragmentCap)
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
}
@@ -55,16 +49,11 @@ struct RelayController {
// TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Thin chains (degree <= 2): every hop counts and flood cost is
// minimal, so relay at full incoming depth
// - Announces get a bit more headroom
let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5)))
}
if degree <= 2 {
return ttlCap
}
let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
}()
-25
View File
@@ -1,25 +0,0 @@
//
// TestEnvironment.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Process-level test-environment detection for singletons that must swap a
/// real OS-backed dependency (keychain, persistent defaults, notifications)
/// for an in-memory one under test. Mirrors the detection already used by
/// `NotificationService` and `LocationStateManager`.
enum TestEnvironment {
/// True when running under XCTest / Swift Testing or in CI.
static let isRunningTests: Bool = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}()
}
+1 -85
View File
@@ -1,7 +1,6 @@
import BitFoundation
import Foundation
import Combine
import CoreBluetooth
/// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too.
@@ -13,27 +12,9 @@ struct TransportPeerSnapshot: Equatable, Hashable {
let lastSeen: Date
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
case peerConnected(PeerID)
case peerDisconnected(PeerID)
case peerListUpdated([PeerID])
case peerSnapshotsUpdated([TransportPeerSnapshot])
case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus)
case bluetoothStateUpdated(CBManagerState)
}
protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
protocol Transport: AnyObject {
// Event sink
var delegate: BitchatDelegate? { get set }
// Typed event sink for transport-domain events. Prefer this over BitchatDelegate for new code.
var eventDelegate: TransportEventDelegate? { get set }
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
@@ -61,27 +42,7 @@ protocol Transport: AnyObject {
func getFingerprint(for peerID: PeerID) -> String?
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID)
// Noise identity/session access. Narrow, purpose-named wrappers so the
// underlying NoiseEncryptionService (and its peer-binding/session
// orchestration) is never exposed outside the transport.
/// The remote static public key of the Noise session with `peerID`, if established.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
/// Fingerprint of our own Noise static identity key.
func noiseIdentityFingerprint() -> String
/// Our Noise static public key (Curve25519 key agreement).
func noiseStaticPublicKeyData() -> Data
/// Our Noise signing public key (Ed25519).
func noiseSigningPublicKeyData() -> Data
/// Signs `data` with our Noise signing key.
func noiseSignData(_ data: Data) -> Data?
/// Verifies an Ed25519 `signature` over `data` against `publicKey`.
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool
/// Registers session-lifecycle callbacks (peer authenticated / handshake required).
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
)
func getNoiseService() -> NoiseEncryptionService
// Messaging
func sendMessage(_ content: String, mentions: [String])
@@ -105,19 +66,6 @@ protocol Transport: AnyObject {
}
extension Transport {
// Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
func noiseIdentityFingerprint() -> String { "" }
func noiseStaticPublicKeyData() -> Data { Data() }
func noiseSigningPublicKeyData() -> Data { Data() }
func noiseSignData(_ data: Data) -> Data? { nil }
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false }
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
@@ -136,36 +84,4 @@ protocol TransportPeerEventsDelegate: AnyObject {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
}
extension BitchatDelegate {
@MainActor
func receiveTransportEvent(_ event: TransportEvent) {
switch event {
case .messageReceived(let message):
didReceiveMessage(message)
case let .publicMessageReceived(peerID, nickname, content, timestamp, messageID):
didReceivePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
case .peerConnected(let peerID):
didConnectToPeer(peerID)
case .peerDisconnected(let peerID):
didDisconnectFromPeer(peerID)
case .peerListUpdated(let peers):
didUpdatePeerList(peers)
case .peerSnapshotsUpdated:
break
case let .messageDeliveryStatusUpdated(messageID, status):
didUpdateMessageDeliveryStatus(messageID, status: status)
case .bluetoothStateUpdated(let state):
didUpdateBluetoothState(state)
}
}
}
extension BLEService: Transport {}
+15 -82
View File
@@ -11,17 +11,13 @@ enum TransportConfig {
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
// reaches as far as text. Dense graphs clamp harder in RelayController.
static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
@@ -44,47 +40,14 @@ enum TransportConfig {
static let blePendingNotificationsCapCount: Int = 128
static let bleNotificationRetryDelayMs: Int = 25
static let bleNotificationRetryMaxAttempts: Int = 80
// Sample interval for notification backpressure logs (fire per fragment
// during media transfers).
static let bleBackpressureLogInterval: Int = 25
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
static let nostrInboundEventDedupCap: Int = 4096
static let nostrInboundEventDedupTrimTarget: Int = 3072
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Bounded per-relay inbound frame buffer. Each relay connection owns its
// own serial verify pipeline; if a relay floods faster than its Schnorr
// verification drains, the oldest buffered frames for THAT relay are
// dropped (bufferingNewest) so one relay cannot stall other relays.
// Nostr inbound is already best-effort (relays are redundant and events
// replay), so dropping a flooding relay's backlog is safe. Together with
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
// cap × maxFrameBytes (128 MiB) per hostile relay bounded, not zero.
static let nostrInboundPerRelayBufferCap: Int = 256
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
// .maximumMessageSize (oversized frames fail the receive instead of
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
// and most public relays reject events beyond ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
// (first + every Nth audit); violations always log at error level.
static let conversationStoreAuditLogInterval: Int = 10
// Sample interval for the mirrored-republish debug line in the ID-only
// delivery fan-out (first + every Nth republish).
static let conversationStoreMirroredRepublishLogInterval: Int = 25
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
@@ -113,21 +76,19 @@ enum TransportConfig {
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0
static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30
// Isolated nodes accept the weakest usable links a fringe connection
// beats no connection. Relaxed floor sits at CoreBluetooth's practical
// reporting limit so prolonged isolation gates on nothing but decode.
static let bleRSSIIsolatedBase: Int = -95
static let bleRSSIIsolatedRelaxed: Int = -100
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
static let bleRecentTimeoutCountThreshold: Int = 3
static let bleRSSIIsolatedBase: Int = -90
static let bleRSSIIsolatedRelaxed: Int = -92
static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80
// How long without seeing traffic before we sanity-check the direct link
// Lowered to make connectedreachable icon changes react faster when walking out of range
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
// How long to retain a peer as "reachable" (not directly connected) since lastSeen.
// Must comfortably exceed the worst-case dense announce interval (38s) plus a
// missed cycle, so duty-cycled nodes don't forget peers between announces.
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 60.0 // verified/favorites
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
@@ -184,27 +145,7 @@ enum TransportConfig {
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
static let nostrRelayBackoffMultiplier: Double = 2.0
static let nostrRelayMaxReconnectAttempts: Int = 10
// Reconnect delays get ±20% random jitter so relays that dropped together
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
static let nostrRelayBackoffJitterRatio: Double = 0.2
static let nostrRelayDefaultFetchLimit: Int = 100
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10
// Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion
// eviction at the cap, plus an age sweep on connect attempts. Durable
// subscription intent survives in subscriptionRequestState either way.
static let nostrPendingSubscriptionsPerRelayCap: Int = 64
static let nostrPendingSubscriptionTTLSeconds: TimeInterval = 600.0
// Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance.
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
@@ -228,10 +169,8 @@ enum TransportConfig {
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Store-and-forward for directed packets at relays. Spooled packets retry
// on each maintenance flush until the window lapses; a longer window lets
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
static let bleDirectedSpoolWindowSeconds: TimeInterval = 60.0
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
// Log/UI debounce windows
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
@@ -241,12 +180,6 @@ enum TransportConfig {
// Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90
// Rediscovery ignore windows after a failed link, by failure kind:
// a connect attempt that timed out means the peer likely isn't reachable,
// so back off; a dropped established connection (walked out of range)
// usually returns, so only pause long enough for CoreBluetooth to settle.
static let bleTimeoutDiscoveryIgnoreSeconds: TimeInterval = 15.0
static let bleDisconnectDiscoveryIgnoreSeconds: TimeInterval = 3.0
// Content hashing / formatting
static let contentKeyPrefixLength: Int = 256
+12 -14
View File
@@ -4,11 +4,9 @@ import Foundation
final class VerificationService {
static let shared = VerificationService()
// Injected running transport (do NOT create new BLEService). Noise
// identity operations go through the transport's narrow noise* wrappers
// so the raw NoiseEncryptionService is never exposed.
private var transport: Transport?
func configure(with transport: Transport) { self.transport = transport }
// Injected Noise service from the running transport (do NOT create new BLEService)
private var noise: NoiseEncryptionService?
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
/// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable {
@@ -79,16 +77,16 @@ final class VerificationService {
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value
}
guard let transport = transport else { return nil }
let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString()
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
guard let noise = noise else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
guard let sig = transport.noiseSignData(msg) else { return nil }
guard let sig = noise.signData(msg) else { return nil }
let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex,
@@ -110,8 +108,8 @@ final class VerificationService {
if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let transport = transport else { return nil }
let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
guard let noise = noise else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil
}
@@ -135,7 +133,7 @@ final class VerificationService {
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil }
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
@@ -180,7 +178,7 @@ final class VerificationService {
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return transport.noiseVerifySignature(signature, for: msg, publicKey: pub)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub)
}
}
-8
View File
@@ -12,11 +12,6 @@ import CryptoKit
enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data }
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
// and shifts in decode would silently overflow to garbage values.
static let maxP = 32
// Derive P from FPR (~ 1 / 2^P)
static func deriveP(targetFpr: Double) -> Int {
let f = max(0.000001, min(0.25, targetFpr))
@@ -71,9 +66,6 @@ enum GCSFilter {
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
// Reject out-of-range parameters rather than decoding garbage: callers
// treat the result as "peer has nothing" and fall back to sending data.
guard p >= 1, p <= maxP, m > 1 else { return [] }
var values: [UInt64] = []
let reader = BitReader(data)
var acc: UInt64 = 0
+8 -11
View File
@@ -128,15 +128,17 @@ final class GossipSyncManager {
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
guard let self = self else { return }
var types: SyncTypeFlags = .publicMessages
self.sendRequestSync(to: peerID, types: .publicMessages)
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
types.formUnion(.fragment)
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fragment)
}
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
types.formUnion(.fileTransfer)
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fileTransfer)
}
}
self.sendRequestSync(to: peerID, types: types)
}
}
@@ -391,18 +393,13 @@ final class GossipSyncManager {
cleanupStaleAnnouncementsIfNeeded(now: now)
requestSyncManager.cleanup() // Cleanup expired sync requests
var dueTypes: SyncTypeFlags = []
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
dueTypes.formUnion(syncSchedules[index].types)
sendPeriodicSync(for: syncSchedules[index].types)
}
}
if !dueTypes.isEmpty {
sendPeriodicSync(for: dueTypes)
}
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
-339
View File
@@ -1,339 +0,0 @@
//
// Theme.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// A user-selectable app-wide visual theme. Persisted by raw value.
enum AppTheme: String, CaseIterable, Identifiable {
case matrix
case liquidGlass
var id: String { rawValue }
/// UserDefaults key backing the theme selection.
static let storageKey = "appTheme"
var displayNameKey: LocalizedStringKey {
switch self {
case .matrix: return "app_info.appearance.matrix"
case .liquidGlass: return "app_info.appearance.liquid_glass"
}
}
/// Font design used for themed text. Matrix keeps the terminal monospace;
/// liquid glass uses the system default.
var bodyFontDesign: Font.Design {
switch self {
case .matrix: return .monospaced
case .liquidGlass: return .default
}
}
/// Whether chrome surfaces (header/composer bars, input field) render as
/// translucent glass/material instead of the flat matrix background.
var usesGlassChrome: Bool {
self == .liquidGlass
}
/// Discriminator mixed into per-message formatting caches so cached
/// AttributedStrings from one theme are never served under another.
/// Empty for matrix to keep its historical cache keys.
var formatCacheVariant: String {
switch self {
case .matrix: return ""
case .liquidGlass: return "lg:"
}
}
/// Resolves the semantic color palette for this theme under the given color scheme.
func palette(for colorScheme: ColorScheme) -> ThemePalette {
switch self {
case .matrix:
return .matrix(colorScheme)
case .liquidGlass:
return .liquidGlass(colorScheme)
}
}
}
/// Semantic colors for the active theme, resolved against the current color scheme.
/// Views should consume these via `@ThemedPalette` rather than computing colors inline.
struct ThemePalette {
/// Primary window/sheet background.
let background: Color
/// Primary text color.
let primary: Color
/// De-emphasized text (timestamps, hints, captions).
let secondary: Color
/// Interactive tint (buttons, toggles, selection).
let accent: Color
/// Location/geohash channel accent (badges, counts, subtitles).
let locationAccent: Color
/// Informational accent (links, read receipts, teleport markers).
let accentBlue: Color
/// Destructive/error accent.
let alertRed: Color
/// Hairline separators.
let divider: Color
static func matrix(_ colorScheme: ColorScheme) -> ThemePalette {
let isDark = colorScheme == .dark
let green = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return ThemePalette(
background: isDark ? Color.black : Color.white,
primary: green,
secondary: green.opacity(0.8),
accent: green,
locationAccent: green,
accentBlue: Color(red: 0.0, green: 0.478, blue: 1.0),
alertRed: Color(red: 0.75, green: 0.1, blue: 0.1),
divider: isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
)
}
static func liquidGlass(_ colorScheme: ColorScheme) -> ThemePalette {
ThemePalette(
background: systemBackground,
primary: .primary,
secondary: .secondary,
accent: .blue,
locationAccent: .green,
accentBlue: .blue,
alertRed: .red,
divider: separator
)
}
private static var systemBackground: Color {
#if os(iOS)
Color(UIColor.systemBackground)
#else
Color(NSColor.windowBackgroundColor)
#endif
}
private static var separator: Color {
#if os(iOS)
Color(UIColor.separator)
#else
Color(NSColor.separatorColor)
#endif
}
}
private struct AppThemeKey: EnvironmentKey {
static let defaultValue: AppTheme = .matrix
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}
/// Resolves the active theme's palette against the view's color scheme.
///
/// @ThemedPalette private var palette
/// var body: some View { Text("hi").foregroundColor(palette.primary) }
@propertyWrapper
struct ThemedPalette: DynamicProperty {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
var wrappedValue: ThemePalette { theme.palette(for: colorScheme) }
}
// MARK: - Themed view helpers
/// Themed replacement for `.font(.bitchatSystem(size:weight:design: .monospaced))`:
/// monospaced under matrix, system default under liquid glass.
private struct ThemedFontModifier: ViewModifier {
@Environment(\.appTheme) private var theme
let size: CGFloat
let weight: Font.Weight
func body(content: Content) -> some View {
content.font(.bitchatSystem(size: size, weight: weight, design: theme.bodyFontDesign))
}
}
/// Root backdrop. Matrix gets its flat background; glass gets a subtle static
/// gradient with a soft tinted glow glass panels need visual texture behind
/// them to refract, and collapse to flat gray over a solid color.
struct ThemedRootBackground: View {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
var body: some View {
if theme.usesGlassChrome {
let isDark = colorScheme == .dark
ZStack {
LinearGradient(
colors: isDark
? [Color(red: 0.09, green: 0.10, blue: 0.15), Color(red: 0.04, green: 0.04, blue: 0.07)]
: [Color(red: 0.93, green: 0.95, blue: 1.0), Color(red: 0.98, green: 0.97, blue: 0.99)],
startPoint: .top,
endPoint: .bottom
)
RadialGradient(
colors: [Color.blue.opacity(isDark ? 0.22 : 0.12), .clear],
center: .topLeading,
startRadius: 0,
endRadius: 600
)
RadialGradient(
colors: [Color.purple.opacity(isDark ? 0.14 : 0.08), .clear],
center: .bottomTrailing,
startRadius: 0,
endRadius: 500
)
}
.ignoresSafeArea()
} else {
palette.background
}
}
}
/// Wraps glass-shape content in real Liquid Glass on OS 26+, with a material
/// fallback below that keeps the frosted look.
private struct GlassPanel<S: Shape>: ViewModifier {
let shape: S
@ViewBuilder
func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(iOS 26.0, macOS 26.0, *) {
content.glassEffect(.regular, in: shape)
} else {
materialFallback(content)
}
#else
materialFallback(content)
#endif
}
private func materialFallback(_ content: Content) -> some View {
content
.background(shape.fill(.ultraThinMaterial))
.overlay(shape.stroke(Color.white.opacity(0.15), lineWidth: 0.5))
}
}
/// Chrome surface for the header and composer. Matrix keeps the original flat
/// edge-to-edge wash; glass floats the content as an inset Liquid Glass panel
/// (content is expected to scroll underneath via safe-area insets).
private struct ThemedChromePanelModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let edge: VerticalEdge
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 18, style: .continuous)))
.padding(.horizontal, 8)
.padding(edge == .top ? .top : .bottom, 4)
} else {
content.background(palette.background.opacity(0.95))
}
}
}
/// Background for the composer input field. Matrix keeps its translucent fill;
/// glass leaves it clear the field sits inside the composer's glass panel,
/// and glass cannot sample other glass.
private struct ThemedInputBackgroundModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
private var shape: RoundedRectangle {
RoundedRectangle(cornerRadius: 14, style: .continuous)
}
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
} else {
content.background(
shape.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
}
}
}
extension View {
func bitchatFont(size: CGFloat, weight: Font.Weight = .regular) -> some View {
modifier(ThemedFontModifier(size: size, weight: weight))
}
func themedChromePanel(edge: VerticalEdge) -> some View {
modifier(ThemedChromePanelModifier(edge: edge))
}
func themedInputBackground() -> some View {
modifier(ThemedInputBackgroundModifier())
}
/// Floating surface for popover-style boxes (autocomplete, command
/// suggestions): glass panel under liquid glass, the original flat
/// background + hairline stroke under matrix.
func themedOverlayPanel() -> some View {
modifier(ThemedOverlayPanelModifier())
}
/// Root background for sheets same backdrop as the main window so every
/// surface speaks one visual language.
func themedSheetBackground() -> some View {
background(ThemedRootBackground())
}
/// Flat background wash for bars/headers inside sheets. Matrix keeps its
/// opaque wash; glass goes transparent so the backdrop gradient shows.
func themedSurface(opacity: Double = 1.0) -> some View {
modifier(ThemedSurfaceModifier(opacity: opacity))
}
}
private struct ThemedSurfaceModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let opacity: Double
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
} else {
content.background(palette.background.opacity(opacity))
}
}
}
private struct ThemedOverlayPanelModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 12, style: .continuous)))
} else {
content
.background(palette.background)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(palette.secondary.opacity(0.3), lineWidth: 1)
)
}
}
}
@@ -1,35 +0,0 @@
import CoreBluetooth
import Foundation
struct ChatBluetoothAlertUpdate: Equatable {
let isPresented: Bool
let message: String?
}
enum ChatBluetoothAlertPolicy {
static func update(for state: CBManagerState) -> ChatBluetoothAlertUpdate {
switch state {
case .poweredOff:
ChatBluetoothAlertUpdate(
isPresented: true,
message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
)
case .unauthorized:
ChatBluetoothAlertUpdate(
isPresented: true,
message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
)
case .unsupported:
ChatBluetoothAlertUpdate(
isPresented: true,
message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
)
case .poweredOn:
ChatBluetoothAlertUpdate(isPresented: false, message: "")
case .unknown, .resetting:
ChatBluetoothAlertUpdate(isPresented: false, message: nil)
@unknown default:
ChatBluetoothAlertUpdate(isPresented: false, message: nil)
}
}
}
@@ -1,153 +0,0 @@
import BitFoundation
import Foundation
/// The narrow surface `ChatComposerCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatComposerContext: AnyObject {
// MARK: Autocomplete UI state
var autocompleteSuggestions: [String] { get set }
var autocompleteRange: NSRange? { get set }
var showAutocomplete: Bool { get set }
var selectedAutocompleteIndex: Int { get set }
/// Computes mention suggestions for the text up to the cursor.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?)
/// Replaces the matched range in `text` with the chosen suggestion.
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// The transport's own nickname (excluded from autocomplete candidates).
var meshNickname: String { get }
func meshPeerNicknames() -> [PeerID: String]
// MARK: Geohash identity (shared with the other contexts)
var geoNicknames: [String: String] { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatComposerContext {
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
// `geoNicknames`, `meshPeerNicknames()`, and
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten nested service accesses into intent-named calls.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?) {
autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition)
}
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
autocompleteService.applySuggestion(suggestion, to: text, range: range)
}
var meshNickname: String {
meshService.myNickname
}
}
@MainActor
final class ChatComposerCoordinator {
private unowned let context: any ChatComposerContext
init(context: any ChatComposerContext) {
self.context = context
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
let peerCandidates = autocompleteCandidates()
let (suggestions, range) = context.autocompleteQuery(
for: text,
peers: peerCandidates,
cursorPosition: cursorPosition
)
if !suggestions.isEmpty {
context.autocompleteSuggestions = suggestions
context.autocompleteRange = range
context.showAutocomplete = true
context.selectedAutocompleteIndex = 0
} else {
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.showAutocomplete = false
context.selectedAutocompleteIndex = 0
}
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
guard let range = context.autocompleteRange else { return text.count }
text = context.applyAutocompleteSuggestion(nickname, to: text, range: range)
context.showAutocomplete = false
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.selectedAutocompleteIndex = 0
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
}
func parseMentions(from content: String) -> [String] {
let regex = ChatViewModel.Patterns.mention
let nsContent = content as NSString
let matches = regex.matches(
in: content,
options: [],
range: NSRange(location: 0, length: nsContent.length)
)
let peerNicknames = context.meshPeerNicknames()
var validTokens = Set(peerNicknames.values)
validTokens.insert(context.nickname)
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
var mentions: [String] = []
for match in matches {
guard let range = Range(match.range(at: 1), in: content) else { continue }
let mentionedName = String(content[range])
if validTokens.contains(mentionedName) {
mentions.append(mentionedName)
}
}
return Array(Set(mentions))
}
}
private extension ChatComposerCoordinator {
func autocompleteCandidates() -> [String] {
switch context.activeChannel {
case .mesh:
let values = context.meshPeerNicknames().values
return Array(values.filter { $0 != context.meshNickname })
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in context.geoNicknames {
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
tokens.remove(myToken)
}
return Array(tokens)
}
}
}
@@ -1,119 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatDeliveryCoordinator` needs from its owner.
///
/// Coordinators should depend on the minimal context they actually use rather
/// than holding an `unowned` back-reference to the whole `ChatViewModel`. This
/// keeps the coordinator independently testable (see
/// `ChatDeliveryCoordinatorContextTests`) and makes its true dependencies
/// explicit. This protocol is the exemplar for migrating the other
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
@MainActor
protocol ChatDeliveryContext: AnyObject {
var isStartupPhase: Bool { get }
/// Applies a delivery status to every copy of the message across
/// conversations (`ConversationStore` intent, ID-only: the store's
/// message-ID conversation map resolves which conversations hold the
/// message, including mirrored ephemeral/stable private copies). The
/// no-downgrade rule is enforced in the store. Returns `false` when the
/// message is unknown or no copy changed.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
/// Current delivery status of the message in whichever conversation holds it.
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
/// Message IDs across all direct conversations (read-receipt pruning).
func privateMessageIDs() -> Set<String>
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
/// Returns the number of receipts removed. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
/// Confirms receipt so the message router stops retaining the message for resend.
func markMessageDelivered(_ messageID: String)
}
extension ChatViewModel: ChatDeliveryContext {
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
conversations.setDeliveryStatus(status, forMessageID: messageID)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
conversations.deliveryStatus(forMessageID: messageID)
}
func privateMessageIDs() -> Set<String> {
conversations.directMessageIDs()
}
func notifyUIChanged() {
objectWillChange.send()
}
func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID)
}
}
/// Thin mapper from delivery events (read receipts, transport delivery
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
/// retention cleanup. The store's message-ID conversation map replaces the
/// positional `messageLocationIndex` this coordinator used to maintain.
final class ChatDeliveryCoordinator {
private unowned let context: any ChatDeliveryContext
init(context: any ChatDeliveryContext) {
self.context = context
}
@MainActor
func cleanupOldReadReceipts() {
guard !context.isStartupPhase else { return }
let validMessageIDs = context.privateMessageIDs()
guard !validMessageIDs.isEmpty else { return }
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
if removedCount > 0 {
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
}
}
@MainActor
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
updateMessageDeliveryStatus(
receipt.originalMessageID,
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
)
}
@MainActor
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
updateMessageDeliveryStatus(messageID, status: status)
}
@MainActor
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
context.deliveryStatus(forMessageID: messageID)
}
@MainActor
@discardableResult
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
switch status {
case .delivered, .read:
// Confirmed receipt stop retaining the message for resend.
context.markMessageDelivered(messageID)
default:
break
}
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
return false
}
context.notifyUIChanged()
return true
}
}
@@ -1,67 +0,0 @@
import Foundation
struct ChatFavoriteStatusSnapshot: Equatable {
let peerNickname: String
let peerNostrPublicKey: String?
let isFavorite: Bool
let theyFavoritedUs: Bool
init(
peerNickname: String,
peerNostrPublicKey: String?,
isFavorite: Bool,
theyFavoritedUs: Bool
) {
self.peerNickname = peerNickname
self.peerNostrPublicKey = peerNostrPublicKey
self.isFavorite = isFavorite
self.theyFavoritedUs = theyFavoritedUs
}
init(_ relationship: FavoritesPersistenceService.FavoriteRelationship) {
self.peerNickname = relationship.peerNickname
self.peerNostrPublicKey = relationship.peerNostrPublicKey
self.isFavorite = relationship.isFavorite
self.theyFavoritedUs = relationship.theyFavoritedUs
}
}
enum ChatFavoritePersistenceAction: Equatable {
case add(nickname: String, nostrKey: String?)
case remove
}
enum ChatFavoriteNotificationDecision: Equatable {
case none
case send(isFavorite: Bool)
}
struct ChatFavoriteTogglePlan: Equatable {
let persistenceAction: ChatFavoritePersistenceAction
let notification: ChatFavoriteNotificationDecision
}
enum ChatFavoriteTogglePolicy {
static func plan(
currentStatus: ChatFavoriteStatusSnapshot?,
fallbackNickname: String?,
bridgedNostrKey: String?
) -> ChatFavoriteTogglePlan {
let wasFavorite = currentStatus?.isFavorite ?? false
if wasFavorite {
return ChatFavoriteTogglePlan(
persistenceAction: .remove,
notification: .send(isFavorite: false)
)
}
return ChatFavoriteTogglePlan(
persistenceAction: .add(
nickname: currentStatus?.peerNickname ?? fallbackNickname ?? "Unknown",
nostrKey: currentStatus?.peerNostrPublicKey ?? bridgedNostrKey
),
notification: currentStatus?.theyFavoritedUs == true ? .send(isFavorite: true) : .none
)
}
}
@@ -1,363 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatLifecycleCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state
var messages: [BitchatMessage] { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Clears the peer's unread flag (store unread state only).
func markPrivateChatRead(_ peerID: PeerID)
var sentReadReceipts: Set<String> { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
/// The owner-level read pass (chat manager + receipts); used for the
/// delayed re-run after the app becomes active.
func markPrivateMessagesAsRead(from peerID: PeerID)
/// Marks the chat read in the private chat manager (sends pending mesh READ acks).
func markChatAsRead(from peerID: PeerID)
/// Schedules main-actor work after a UI-timing delay. Injected so tests
/// can run the work synchronously instead of polling wall-clock queues.
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
func addSystemMessage(_ content: String)
// MARK: Peers & sessions
func peerNickname(for peerID: PeerID) -> String?
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func stopMeshServices()
/// Re-reads the transport's current Bluetooth state and updates the alert UI.
func refreshBluetoothState()
// MARK: Routing & receipts
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Nostr & geohash
var isTeleported: Bool { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func recordGeoParticipant(pubkeyHex: String)
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
// MARK: Identity persistence
/// Forces the identity manager to persist its state now.
func forceSaveIdentity()
/// Confirms the Noise identity key is still present in the keychain.
@discardableResult
func verifyIdentityKeyExists() -> Bool
}
extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
// the routing/ack members, `isTeleported`,
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
// and `favoriteRelationship(forNoiseKey:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func markChatAsRead(from peerID: PeerID) {
privateChatManager.markAsRead(from: peerID)
}
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Task { @MainActor in
work()
}
}
}
func stopMeshServices() {
meshService.stopServices()
}
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
}
}
func forceSaveIdentity() {
identityManager.forceSave()
}
@discardableResult
func verifyIdentityKeyExists() -> Bool {
keychain.verifyIdentityKeyExists()
}
}
@MainActor
final class ChatLifecycleCoordinator {
private unowned let context: any ChatLifecycleContext
init(context: any ChatLifecycleContext) {
self.context = context
}
func handleDidBecomeActive() {
context.refreshBluetoothState()
guard let peerID = context.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let context = self.context
context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
context?.markPrivateMessagesAsRead(from: peerID)
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(context.nickname) took a screenshot *"
if let peerID = context.selectedPrivateChatPeer {
sendPrivateScreenshotNotificationIfPossible(
screenshotMessage,
to: peerID
)
appendPrivateScreenshotNotice(for: peerID)
return
}
switch context.activeChannel {
case .mesh:
context.sendMeshMessage(
screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date()
)
case .location(let channel):
sendPublicGeohashScreenshotMessage(
screenshotMessage,
channel: channel
)
}
context.addSystemMessage("you took a screenshot")
}
func saveIdentityState() {
context.forceSaveIdentity()
context.verifyIdentityKeyExists()
}
func applicationWillTerminate() {
context.stopMeshServices()
saveIdentityState()
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
context.markChatAsRead(from: peerID)
if peerID.isGeoDM,
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let messages = context.privateMessages(for: peerID)
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !context.sentReadReceipts.contains(message.id) else { continue }
SecureLogger.debug(
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: .session
)
context.sendGeohashReadReceipt(
message.id,
toRecipientHex: recipientHex,
from: identity
)
context.markReadReceiptSent(message.id)
}
return
}
var noiseKeyHex: PeerID?
var peerNostrPubkey: String?
if let noiseKey = Data(hexString: peerID.id),
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
} else if let peer = context.unifiedPeer(for: peerID) {
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
context.markPrivateChatRead(noiseKeyHex)
}
}
guard peerNostrPubkey != nil else { return }
for message in getPrivateChatMessages(for: peerID) {
guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else {
continue
}
guard !context.sentReadReceipts.contains(message.id) else { continue }
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: context.myPeerID,
readerNickname: context.nickname
)
let recipientPeerID = peerID.isHex
? peerID
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
context.routeReadReceipt(receipt, to: recipientPeerID)
context.markReadReceiptSent(message.id)
}
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return context.messages }
return getPrivateChatMessages(for: peerID)
}
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
combined.append(contentsOf: context.privateMessages(for: peerID))
if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID {
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
}
}
var bestByID: [String: BitchatMessage] = [:]
for message in combined {
if let existing = bestByID[message.id] {
let existingRank = deliveryStatusRank(existing.deliveryStatus)
let candidateRank = deliveryStatusRank(message.deliveryStatus)
if candidateRank > existingRank || (candidateRank == existingRank && message.timestamp > existing.timestamp) {
bestByID[message.id] = message
}
} else {
bestByID[message.id] = message
}
}
return bestByID.values.sorted { $0.timestamp < $1.timestamp }
}
}
private extension ChatLifecycleCoordinator {
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
guard let peerNickname = context.peerNickname(for: peerID) else { return }
let sessionState = context.noiseSessionState(for: peerID)
switch sessionState {
case .established:
context.routePrivateMessage(
message,
to: peerID,
recipientNickname: peerNickname,
messageID: UUID().uuidString
)
case .none, .failed, .handshakeQueued, .handshaking:
SecureLogger.debug(
"Skipping screenshot notification to \(peerID) - no established session",
category: .security
)
}
}
func appendPrivateScreenshotNotice(for peerID: PeerID) {
let notice = BitchatMessage(
sender: "system",
content: "you took a screenshot",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: context.peerNickname(for: peerID),
senderPeerID: context.myPeerID
)
context.appendPrivateMessage(notice, to: peerID)
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
nickname: context.nickname,
teleported: context.isTeleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
}
}
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
guard let status else { return 0 }
switch status {
case .failed: return 1
case .sending: return 2
case .sent: return 3
case .partiallyDelivered: return 4
case .delivered: return 5
case .read: return 6
}
}
}
@@ -1,57 +0,0 @@
import BitFoundation
import Foundation
enum ChatMediaPreparationError: Error, Equatable {
case encodingFailed
case voiceNoteTooLarge(bytes: Int)
case imageTooLarge(bytes: Int)
}
struct ChatPreparedImage {
let outputURL: URL
let packet: BitchatFilePacket
}
enum ChatMediaPreparation {
static func prepareVoiceNotePacket(at url: URL) throws -> BitchatFilePacket {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attributes[.size] as? Int else {
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 0)
}
guard fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: fileSize)
}
let data = try Data(contentsOf: url)
let packet = BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "audio/mp4",
content: data
)
guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed }
return packet
}
static func prepareImagePacket(from sourceURL: URL) throws -> ChatPreparedImage {
let outputURL = try ImageUtils.processImage(at: sourceURL)
do {
let data = try Data(contentsOf: outputURL)
guard data.count <= FileTransferLimits.maxImageBytes else {
throw ChatMediaPreparationError.imageTooLarge(bytes: data.count)
}
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed }
return ChatPreparedImage(outputURL: outputURL, packet: packet)
} catch {
try? FileManager.default.removeItem(at: outputURL)
throw error
}
}
}
@@ -1,362 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
#if os(iOS)
import UIKit
#endif
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its
/// true dependencies explicit.
@MainActor
protocol ChatMediaTransferContext: AnyObject {
// MARK: Composition state
var canSendMediaInCurrentContext: Bool { get }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
func nicknameForPeer(_ peerID: PeerID) -> String
func currentPublicSender() -> (name: String, peerID: PeerID)
// MARK: Message state
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Appends a public message via the single-writer store intent
/// (immediate: outgoing media placeholders must render without batching).
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func removeMessage(withID messageID: String, cleanupFile: Bool)
func addSystemMessage(_ content: String)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Delivery status & dedup
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Mesh file transfer
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String)
}
extension ChatViewModel: ChatMediaTransferContext {
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
// `currentPublicSender()`,
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
// `addSystemMessage(_:)`, `notifyUIChanged()`,
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
// and `recordContentKey(_:timestamp:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId)
}
}
@MainActor
final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
init(context: any ChatMediaTransferContext) {
self.context = context
}
func sendVoiceNote(at url: URL) {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
context.addSystemMessage("Voice notes are only available in mesh chats.")
return
}
let targetPeer = context.selectedPrivateChatPeer
let message = enqueueMediaMessage(
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
targetPeer: targetPeer
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
Task.detached(priority: .userInitiated) { [weak self] in
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.context.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
}
}
}
}
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached { [weak self] in
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run { [weak self] in
guard let self else { return }
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached { [weak self] in
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#endif
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
context.addSystemMessage("Images are only available in mesh chats.")
return
}
let targetPeer = context.selectedPrivateChatPeer
do {
try ImageUtils.validateImageSource(at: sourceURL)
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
context.addSystemMessage("Failed to prepare image for sending.")
return
}
Task.detached(priority: .userInitiated) { [weak self] in
do {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
await MainActor.run { [weak self] in
guard let self else { return }
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer
)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
} else {
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
}
}
} catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.context.addSystemMessage("Image is too large to send.")
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
}
}
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
if let peerID = targetPeer {
message = BitchatMessage(
sender: context.nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: context.nicknameForPeer(peerID),
senderPeerID: context.myPeerID,
deliveryStatus: .sending
)
context.appendPrivateMessage(message, to: peerID)
} else {
let (displayName, senderPeerID) = context.currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
}
let key = context.normalizedContentKey(message.content)
context.recordContentKey(key, timestamp: timestamp)
context.notifyUIChanged()
return message
}
func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId
}
func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)"
}
func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty {
if queue.first == messageID {
queue.removeFirst()
} else if let index = queue.firstIndex(of: messageID) {
queue.remove(at: index)
}
}
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
}
func handleMediaSendFailure(messageID: String, reason: String) {
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
func handleTransferEvent(_ event: TransferProgressManager.Event) {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
context.updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
}
func cleanupLocalFile(forMessage message: BitchatMessage) {
let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
let base = try? applicationFilesDirectory(),
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
safeFilename != ".",
safeFilename != ".." else {
return
}
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
guard target.path.hasPrefix(base.path) else { continue }
do {
try FileManager.default.removeItem(at: target)
} catch CocoaError.fileNoSuchFile {
continue
} catch {
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
}
}
}
func cancelMediaSend(messageID: String) {
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
context.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
}
}
private extension ChatMediaTransferCoordinator {
func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDirectory = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(
at: filesDirectory,
withIntermediateDirectories: true,
attributes: nil
)
return filesDirectory
}
}
@@ -1,505 +0,0 @@
import BitFoundation
import Foundation
import SwiftUI
@MainActor
final class ChatMessageFormatter {
typealias Patterns = MessageFormattingEngine.Patterns
private unowned let viewModel: ChatViewModel
private let meshPalette = MinimalDistancePalette(config: .mesh)
private let nostrPalette = MinimalDistancePalette(config: .nostr)
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
let design = theme.bodyFontDesign
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
let myGeo: NostrIdentity? = {
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
return cached.identity
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
return identity
}
return nil
}()
if let myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
return spid == viewModel.meshService.myPeerID
}
if message.sender == viewModel.nickname { return true }
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
return false
}()
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
return cachedText
}
var result = AttributedString()
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
if message.sender != "system" {
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
senderStyle.link = url
}
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = {
let regex = Patterns.quickCashuPresence
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
let hashtagRegex = Patterns.hashtag
let mentionRegex = Patterns.mention
let cashuRegex = Patterns.cashu
let bolt11Regex = Patterns.bolt11
let lnurlRegex = Patterns.lnurl
let lightningSchemeRegex = Patterns.lightningScheme
let detector = Patterns.linkDetector
let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let hasCashuHint = content.lowercased().contains("cashu")
let hashtagMatches = hasHashtagsHint
? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let mentionMatches = hasMentionsHint
? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let urlMatches = hasURLHint
? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? [])
: []
let cashuMatches = hasCashuHint
? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let lightningMatches = hasLightningHint
? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let bolt11Matches = hasLightningHint
? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let lnurlMatches = hasLightningHint
? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
: []
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
func overlapsMention(_ range: NSRange) -> Bool {
for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 {
return true
}
return false
}
func attachedToMention(_ range: NSRange) -> Bool {
if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex {
var index = content.index(before: swiftRange.lowerBound)
while true {
let character = content[index]
if character.isWhitespace || character.isNewline { break }
if character == "@" { return true }
if index == content.startIndex { break }
index = content.index(before: index)
}
}
return false
}
func isStandaloneHashtag(_ range: NSRange) -> Bool {
guard let swiftRange = Range(range, in: content) else { return false }
if swiftRange.lowerBound == content.startIndex { return true }
let previous = content.index(before: swiftRange.lowerBound)
return content[previous].isWhitespace || content[previous].isNewline
}
var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches
where !overlapsMention(match.range(at: 0))
&& !attachedToMention(match.range(at: 0))
&& isStandaloneHashtag(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "hashtag"))
}
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in urlMatches where !overlapsMention(match.range) {
allMatches.append((match.range, "url"))
}
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "cashu"))
}
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "lightning"))
}
let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) }
func overlapsOccupied(_ range: NSRange) -> Bool {
for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 {
return true
}
return false
}
for match in bolt11Matches
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "bolt11"))
}
for match in lnurlMatches
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "lnurl"))
}
allMatches.sort { $0.range.location < $1.range.location }
var lastEnd = content.startIndex
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
for (range, type) in allMatches {
guard let swiftRange = Range(range, in: content) else { continue }
if lastEnd < swiftRange.lowerBound {
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
if !beforeText.isEmpty {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
}
}
let matchText = String(content[swiftRange])
if type == "mention" {
let (mentionBase, mentionSuffix) = matchText.splitSuffix()
let mySuffix: String? = {
if case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
return String(identity.publicKeyHex.suffix(4))
}
return String(viewModel.meshService.myPeerID.id.prefix(4))
}()
let isMentionToMe: Bool = {
if mentionBase == viewModel.nickname {
if let mySuffix, !mentionSuffix.isEmpty {
return mentionSuffix == "#\(mySuffix)"
}
return mentionSuffix.isEmpty
}
return false
}()
var mentionStyle = AttributeContainer()
mentionStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: design
)
let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor
let at = "@"
result.append(AttributedString("\(at)").mergingAttributes(mentionStyle))
result.append(AttributedString(mentionBase).mergingAttributes(mentionStyle))
if !mentionSuffix.isEmpty {
var lightStyle = mentionStyle
lightStyle.foregroundColor = mentionColor.opacity(0.6)
result.append(AttributedString(mentionSuffix).mergingAttributes(lightStyle))
}
} else if type == "hashtag" {
let token = String(matchText.dropFirst()).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
let attachedToMentionToken: Bool = {
if swiftRange.lowerBound > content.startIndex {
var index = content.index(before: swiftRange.lowerBound)
while true {
let character = content[index]
if character.isWhitespace || character.isNewline { break }
if character == "@" { return true }
if index == content.startIndex { break }
index = content.index(before: index)
}
}
return false
}()
let standalone: Bool = {
if swiftRange.lowerBound == content.startIndex { return true }
let previous = content.index(before: swiftRange.lowerBound)
return content[previous].isWhitespace || content[previous].isNewline
}()
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMentionToken && standalone,
let url = URL(string: "bitchat://geohash/\(token)") {
tagStyle.link = url
tagStyle.underlineStyle = .single
}
result.append(AttributedString(matchText).mergingAttributes(tagStyle))
} else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" {
var spacer = AttributeContainer()
spacer.foregroundColor = baseColor
spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
result.append(AttributedString(" ").mergingAttributes(spacer))
} else {
var matchStyle = AttributeContainer()
matchStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: design
)
if type == "url" {
matchStyle.foregroundColor = isSelf ? .orange : .blue
matchStyle.underlineStyle = .single
if let url = URL(string: matchText) {
matchStyle.link = url
}
}
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
}
if lastEnd < swiftRange.upperBound {
lastEnd = swiftRange.upperBound
}
}
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
if isMentioned {
remainingStyle.font = remainingStyle.font?.bold()
}
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
}
}
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: design)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *")
contentStyle.font = .bitchatSystem(size: 12, design: design).italic()
result.append(content.mergingAttributes(contentStyle))
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: design)
result.append(timestamp.mergingAttributes(timestampStyle))
}
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
return result
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
let design = theme.bodyFontDesign
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"),
let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
return spid == viewModel.meshService.myPeerID
}
if message.sender == viewModel.nickname { return true }
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
return false
}()
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: design)
return AttributedString(message.sender).mergingAttributes(style)
}
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
senderStyle.link = url
}
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
func isSelfMessage(_ message: BitchatMessage) -> Bool {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
let myGeo: NostrIdentity? = {
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
return cached.identity
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
return identity
}
return nil
}()
if let myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
return spid == viewModel.meshService.myPeerID
}
if message.sender == viewModel.nickname { return true }
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
return false
}
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
peerColor(for: message, isDark: isDark)
}
func peerURL(for peerID: PeerID) -> URL? {
URL(string: "bitchat://user/\(peerID.toPercentEncoded())")
}
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
getPeerPaletteColor(for: peerID, isDark: isDark)
}
}
private extension ChatMessageFormatter {
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if spid.isGeoChat || spid.isGeoDM {
let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark)
} else if spid.id.count == 16 {
return getPeerPaletteColor(for: spid, isDark: isDark)
} else {
return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark)
}
}
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
}
func meshSeed(for peerID: PeerID) -> String {
if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() {
return "noise:" + full
}
return peerID.id.lowercased()
}
func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
if peerID == viewModel.meshService.myPeerID {
return .orange
}
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
return color
}
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
}
func currentMeshPaletteSeeds() -> [String: String] {
let myID = viewModel.meshService.myPeerID
var seeds: [String: String] = [:]
for peer in viewModel.allPeers where peer.peerID != myID {
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
}
return seeds
}
func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
let myHex = currentGeohashIdentityHex()
if let myHex, pubkeyHexLowercased == myHex {
return .orange
}
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
return color
}
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
}
func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
var seeds: [String: String] = [:]
let excluded = myHex ?? ""
for person in viewModel.visibleGeohashPeople() where person.id != excluded {
seeds[person.id] = "nostr:" + person.id
}
return seeds
}
func currentGeohashIdentityHex() -> String? {
if case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
return identity.publicKeyHex.lowercased()
}
return nil
}
}
@@ -1,199 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// The surface `ChatNostrCoordinator` needs from its owner.
///
/// Inherits the component contexts (`GeohashSubscriptionContext`,
/// `NostrInboundPipelineContext`, `GeoPresenceContext`) so a single object
/// `ChatViewModel` in production, one mock in tests can back the whole
/// Nostr stack. The members declared here are only the residual
/// favorites/ack glue the slimmed coordinator still owns.
@MainActor
protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineContext, GeoPresenceContext {
var selectedPrivateChatPeer: PeerID? { get }
var nostrKeyMapping: [PeerID: String] { get }
func startPrivateChat(with peerID: PeerID)
func visibleGeohashPeople() -> [GeoPerson]
// MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`)
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Favorites & notifications (shared with the other contexts)
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
}
extension ChatViewModel: ChatNostrContext {
// All requirements including the component-context witnesses declared
// in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`,
// `GeoPresenceTracker.swift`, and the favorites/notification witnesses in
// `ChatPrivateConversationCoordinator.swift`,
// `ChatPeerIdentityCoordinator.swift`, and
// `ChatVerificationCoordinator.swift` already exist on `ChatViewModel`.
}
/// Thin facade over the Nostr stack: owns and wires the three components and
/// keeps the residual favorites/ack glue that fits none of them.
///
/// - `subscriptions`: relay lifecycle and subscription IDs
/// (`GeohashSubscriptionManager`)
/// - `inbound`: the hot event -> message/payload pipeline
/// (`NostrInboundPipeline`)
/// - `presence`: teleport marking, sampling dedup, notification cooldown
/// (`GeoPresenceTracker`)
final class ChatNostrCoordinator {
private weak var context: (any ChatNostrContext)?
let presence: GeoPresenceTracker
let inbound: NostrInboundPipeline
let subscriptions: GeohashSubscriptionManager
init(context: any ChatNostrContext) {
self.context = context
let presence = GeoPresenceTracker(context: context)
let inbound = NostrInboundPipeline(context: context, presence: presence)
self.presence = presence
self.inbound = inbound
self.subscriptions = GeohashSubscriptionManager(context: context, inbound: inbound, presence: presence)
}
@MainActor
func sendDeliveryAckViaNostrEmbedded(
_ message: BitchatMessage,
wasReadBefore: Bool,
senderPubkey: String,
key: Data?
) {
guard let context else { return }
if let _ = key {
if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
}
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug(
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
)
}
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
}
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
)
}
}
}
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
guard let context else { return }
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
if isFavorite {
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: nostrPubkey,
nickname: senderNickname
)
}
var extractedNostrPubkey: String?
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info(
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
category: .session
)
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: extractedNostrPubkey,
nickname: senderNickname
)
}
context.postLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
}
@MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let context else { return }
guard let relationship = context.favoriteRelationship(forNoiseKey: noisePublicKey),
relationship.peerNostrPublicKey != nil else {
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
return
}
let peerID = PeerID(hexData: noisePublicKey)
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
guard let context else { return nil }
for person in context.visibleGeohashPeople() where person.displayName == name {
return person.id
}
for (pub, nick) in context.geoNicknames where nick == name {
return pub
}
return nil
}
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
guard let context else { return }
let convKey = PeerID(nostr_: hex)
context.registerNostrKeyMapping(hex, for: convKey)
context.startPrivateChat(with: convKey)
}
@MainActor
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
guard let context else { return nil }
return context.nostrKeyMapping[senderID]
}
@MainActor
func geohashDisplayName(for convKey: PeerID) -> String {
guard let context else { return convKey.bare }
guard let full = context.nostrKeyMapping[convKey] else {
return convKey.bare
}
return context.displayNameForNostrPubkey(full)
}
}
@@ -1,211 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatOutgoingCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatOutgoingContext: AnyObject {
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var isTeleported: Bool { get }
// MARK: Commands & private messages
func handleCommand(_ command: String)
func updatePrivateChatPeerIfNeeded()
func sendPrivateMessage(_ content: String, to peerID: PeerID)
// MARK: Public timeline (local echo)
func parseMentions(from content: String) -> [String]
/// Appends a public message via the single-writer store intent
/// (immediate: the local echo must render without batching).
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func addSystemMessage(_ content: String)
// MARK: Content dedup
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Outbound routing
/// Stamps "now" as the channel's last public activity (background nudges).
/// (Single mutation path for the owner's `lastPublicActivityAt`; this
/// coordinator never reads it.)
func recordPublicActivity(forChannelKey key: String)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
// MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatOutgoingContext {
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
// `appendPublicMessage(_:to:)`, `addSystemMessage(_:)`,
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The single-writer intent op below lives next to
// its backing state's owner.
func recordPublicActivity(forChannelKey key: String) {
lastPublicActivityAt[key] = Date()
}
}
@MainActor
final class ChatOutgoingCoordinator {
private unowned let context: any ChatOutgoingContext
init(context: any ChatOutgoingContext) {
self.context = context
}
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
if content.hasPrefix("/") {
Task { @MainActor [weak context = self.context] in
context?.handleCommand(content)
}
return
}
if context.selectedPrivateChatPeer != nil {
context.updatePrivateChatPeerIfNeeded()
if let selectedPeer = context.selectedPrivateChatPeer {
context.sendPrivateMessage(content, to: selectedPeer)
}
return
}
let mentions = context.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
appendLocalEcho(preparedMessage.message)
routePublicMessage(
originalContent: content,
mentions: mentions,
geoContext: preparedMessage.geoContext,
messageID: preparedMessage.message.id,
timestamp: preparedMessage.message.timestamp
)
}
}
private extension ChatOutgoingCoordinator {
func preparePublicMessage(
content: String,
trimmed: String,
mentions: [String]
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
var geoContext: ChatViewModel.GeoOutgoingContext?
var displaySender = context.nickname
var localSenderPeerID = context.myPeerID
var messageID: String?
var messageTimestamp = Date()
switch context.activeChannel {
case .mesh:
break
case .location(let channel):
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = context.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = context.isTeleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
nickname: context.nickname,
teleported: teleported
)
messageID = event.id
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
geoContext = (
channel: channel,
event: event,
identity: identity,
teleported: teleported
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return nil
}
}
let message = BitchatMessage(
id: messageID,
sender: displaySender,
content: trimmed,
timestamp: messageTimestamp,
isRelay: false,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions
)
return (message, geoContext)
}
func appendLocalEcho(_ message: BitchatMessage) {
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
}
func routePublicMessage(
originalContent: String,
mentions: [String],
geoContext: ChatViewModel.GeoOutgoingContext?,
messageID: String,
timestamp: Date
) {
switch context.activeChannel {
case .mesh:
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: messageID,
timestamp: timestamp
)
case .location(let channel):
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
Task { @MainActor [weak context = self.context] in
context?.sendGeohash(context: geoContext)
}
}
}
}
@@ -1,662 +0,0 @@
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true
/// dependencies explicit. Several members are flattened service accesses
/// this coordinator implements the `ChatViewModel`-level peer-identity API, so
/// its context members deliberately sit one level below those wrappers
/// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)`
/// vs `getFingerprint(for:)`, ) to avoid call cycles.
@MainActor
protocol ChatPeerIdentityContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (dedup by ID, order preserved, unread carried, old chat removed).
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
var selectedPrivateChatPeer: PeerID? { get set }
var selectedPrivateChatFingerprint: String? { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
func addSystemMessage(_ content: String)
// MARK: Private chat session lifecycle
/// Merges messages stored under alternate peer-ID representations into `peerID`'s chat.
/// Returns `true` when unread messages were discovered during consolidation.
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool
/// Marks read receipts as sent for own messages already delivered/read in
/// `peerID`'s chat. (Single mutation path into the owner's
/// `sentReadReceipts`; this coordinator never touches the raw set.)
func syncReadReceiptsForSentMessages(for peerID: PeerID)
/// Re-targets the private chat session: selection mutates through the
/// `ConversationStore` intent (the store owns selection).
func beginPrivateChatSession(with peerID: PeerID)
func markPrivateMessagesAsRead(from peerID: PeerID)
// MARK: Unified peer service
var connectedPeers: Set<PeerID> { get }
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func unifiedIsBlocked(_ peerID: PeerID) -> Bool
func unifiedToggleFavorite(_ peerID: PeerID)
func unifiedFingerprint(for peerID: PeerID) -> String?
func unifiedPeerID(forNickname nickname: String) -> PeerID?
/// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected.
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID?
// MARK: Mesh & Noise sessions
func peerNickname(for peerID: PeerID) -> String?
func meshPeerNicknames() -> [PeerID: String]
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID)
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func hasNoiseSession(with peerID: PeerID) -> Bool
/// Our own Noise identity fingerprint.
func noiseIdentityFingerprint() -> String
// MARK: Identity store (fingerprints & encryption status)
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID)
/// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`,
/// falling back to `fallback` when none was stored. Returns the migrated fingerprint.
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus?
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID)
func invalidateStoredEncryptionCache(for peerID: PeerID?)
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity?
// MARK: Favorites
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// The persisted favorite relationship for a short (ephemeral) peer ID, if any.
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Removes a favorite from the favorites store.
func removeFavorite(noiseKey: Data)
// MARK: Geohash & Nostr
var geoNicknames: [String: String] { get }
func visibleGeohashPeople() -> [GeoPerson]
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func bridgedNostrPublicKey(for noiseKey: Data) -> String?
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool)
}
extension ChatViewModel: ChatPeerIdentityContext {
// `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`,
// `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`,
// `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`,
// `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`,
// `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`,
// `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`,
// `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`,
// and the conversation-store sync methods are shared requirements with
// the other contexts or satisfied by existing `ChatViewModel` members.
// The single-writer intent op `syncReadReceiptsForSentMessages(for:)`
// lives next to its backing state in `ChatViewModel`. The members below
// flatten nested service accesses into intent-named calls.
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
privateChatManager.consolidateMessages(
for: peerID,
peerNickname: peerNickname,
persistedReadReceipts: sentReadReceipts
)
}
func beginPrivateChatSession(with peerID: PeerID) {
privateChatManager.startChat(with: peerID)
}
func unifiedIsBlocked(_ peerID: PeerID) -> Bool {
unifiedPeerService.isBlocked(peerID)
}
func unifiedToggleFavorite(_ peerID: PeerID) {
unifiedPeerService.toggleFavorite(peerID)
}
func unifiedFingerprint(for peerID: PeerID) -> String? {
unifiedPeerService.getFingerprint(for: peerID)
}
func unifiedPeerID(forNickname nickname: String) -> PeerID? {
unifiedPeerService.getPeerID(for: nickname)
}
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
meshService.getNoiseSessionState(for: peerID)
}
func triggerHandshake(with peerID: PeerID) {
meshService.triggerHandshake(with: peerID)
}
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
if case .established = meshService.getNoiseSessionState(for: peerID) { return true }
return false
}
func hasNoiseSession(with peerID: PeerID) -> Bool {
switch meshService.getNoiseSessionState(for: peerID) {
case .established, .handshaking: return true
case .none, .handshakeQueued, .failed: return false
}
}
func noiseIdentityFingerprint() -> String {
meshService.noiseIdentityFingerprint()
}
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
peerIdentityStore.setFingerprint(fingerprint, for: peerID)
}
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? {
peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback)
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
peerIdentityStore.isVerified(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
peerIdentityStore.setEncryptionStatus(status, for: peerID)
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
peerIdentityStore.cachedEncryptionStatus(for: peerID)
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
}
func invalidateStoredEncryptionCache(for peerID: PeerID?) {
peerIdentityStore.invalidateEncryptionCache(for: peerID)
}
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? {
identityManager.getSocialIdentity(for: fingerprint)
}
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
idBridge.getNostrPublicKey(for: noiseKey)
}
// `favoriteRelationship(forNoiseKey:)` is shared with
// `ChatPrivateConversationContext`; its witness lives in
// `ChatPrivateConversationCoordinator.swift`.
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname
)
}
func removeFavorite(noiseKey: Data) {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
}
}
final class ChatPeerIdentityCoordinator {
private unowned let context: any ChatPeerIdentityContext
init(context: any ChatPeerIdentityContext) {
self.context = context
}
@MainActor
func openMostRelevantPrivateChat() {
let unreadSorted = context.unreadPrivateMessages
.map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
return
}
let recent = context.privateChats
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
.sorted { $0.ts > $1.ts }
if let target = recent.first?.id {
startPrivateChat(with: target)
}
}
@MainActor
func isPeerBlocked(_ peerID: PeerID) -> Bool {
context.unifiedIsBlocked(peerID)
}
@MainActor
func hasUnreadMessages(for peerID: PeerID) -> Bool {
var noiseKeyPeerID: PeerID?
var nostrPeerID: PeerID?
if let peer = context.unifiedPeer(for: peerID) {
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
if let nostrHex = peer.nostrPublicKey {
nostrPeerID = PeerID(nostr_: nostrHex)
}
}
let unreadContext = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: noiseKeyPeerID,
nostrPeerID: nostrPeerID,
nickname: context.peerNickname(for: peerID)
)
return ChatUnreadStateResolver.hasUnreadMessages(
for: unreadContext,
unreadPrivateMessages: context.unreadPrivateMessages,
privateChats: context.privateChats
)
}
@MainActor
func toggleFavorite(peerID: PeerID) {
if let noisePublicKey = peerID.noiseKey {
toggleFavoriteForNoiseKey(noisePublicKey, peerID: peerID)
return
}
context.unifiedToggleFavorite(peerID)
context.notifyUIChanged()
}
@MainActor
func isFavorite(peerID: PeerID) -> Bool {
if let noisePublicKey = peerID.noiseKey {
return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false
}
return context.unifiedPeer(for: peerID)?.isFavorite ?? false
}
@MainActor
func updatePrivateChatPeerIfNeeded() {
guard let chatFingerprint = context.selectedPrivateChatFingerprint,
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
return
}
if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID {
migrateChatState(from: oldPeerID, to: currentPeerID)
context.selectedPrivateChatPeer = currentPeerID
} else if context.selectedPrivateChatPeer == nil {
context.selectedPrivateChatPeer = currentPeerID
}
context.markPrivateChatRead(currentPeerID)
}
@MainActor
func startPrivateChat(with peerID: PeerID) {
guard peerID != context.myPeerID else { return }
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if context.unifiedIsBlocked(peerID) {
context.addSystemMessage(
String(
format: String(
localized: "system.chat.blocked",
comment: "System message when starting chat fails because peer is blocked"
),
locale: .current,
peerNickname
)
)
return
}
if let peer = context.unifiedPeer(for: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
context.addSystemMessage(
String(
format: String(
localized: "system.chat.requires_favorite",
comment: "System message when mutual favorite requirement blocks chat"
),
locale: .current,
peerNickname
)
)
return
}
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
if !peerID.isGeoDM && !peerID.isGeoChat {
switch context.noiseSessionState(for: peerID) {
case .none, .failed:
context.triggerHandshake(with: peerID)
case .handshakeQueued, .handshaking, .established:
break
}
} else {
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
}
context.syncReadReceiptsForSentMessages(for: peerID)
if let fingerprint = getFingerprint(for: peerID) {
context.setStoredFingerprint(fingerprint, for: peerID)
context.selectedPrivateChatFingerprint = fingerprint
} else {
context.selectedPrivateChatFingerprint = nil
}
context.beginPrivateChatSession(with: peerID)
context.markPrivateMessagesAsRead(from: peerID)
}
@MainActor
func endPrivateChat() {
context.selectedPrivateChatPeer = nil
context.selectedPrivateChatFingerprint = nil
}
@MainActor
func handlePeerStatusUpdate() {
updatePrivateChatPeerIfNeeded()
}
func handleFavoriteStatusChanged(_ notification: Notification) {
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
Task { @MainActor [weak context = self.context] in
guard let context else { return }
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
isKeyUpdate,
let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data {
migrateNoiseKeyUpdate(
oldPeerID: PeerID(hexData: oldKey),
newPeerID: PeerID(hexData: peerPublicKey)
)
}
updatePrivateChatPeerIfNeeded()
if let isFavorite = notification.userInfo?["isFavorite"] as? Bool {
let peerID = PeerID(hexData: peerPublicKey)
let action = isFavorite ? "favorited" : "unfavorited"
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
context.addSystemMessage("\(peerNickname) \(action) you")
}
}
}
@MainActor
func updateEncryptionStatusForPeers() {
for peerID in context.connectedPeers {
updateEncryptionStatus(for: peerID)
}
}
@MainActor
func updateEncryptionStatus(for peerID: PeerID) {
if context.hasEstablishedNoiseSession(with: peerID) {
context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
} else if context.hasNoiseSession(with: peerID) {
context.setEncryptionStatus(.noiseHandshaking, for: peerID)
} else {
context.setEncryptionStatus(nil, for: peerID)
}
invalidateEncryptionCache(for: peerID)
}
@MainActor
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let cachedStatus = context.cachedEncryptionStatus(for: peerID) {
return cachedStatus
}
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
let sessionState = context.noiseSessionState(for: peerID)
let status: EncryptionStatus
switch sessionState {
case .established:
status = verifiedEncryptionStatus(for: peerID)
case .handshaking, .handshakeQueued:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking
case .none:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake
case .failed:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
}
context.setCachedEncryptionStatus(status, for: peerID)
return status
}
@MainActor
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
context.invalidateStoredEncryptionCache(for: peerID)
}
@MainActor
func getFingerprint(for peerID: PeerID) -> String? {
context.unifiedFingerprint(for: peerID)
}
@MainActor
func resolveNickname(for peerID: PeerID) -> String {
guard !peerID.isEmpty else { return "unknown" }
if !peerID.isHex {
return peerID.id
}
if let nickname = context.meshPeerNicknames()[peerID] {
return nickname
}
if let fingerprint = getFingerprint(for: peerID),
let identity = context.socialIdentity(forFingerprint: fingerprint) {
if let petname = identity.localPetname {
return petname
}
return identity.claimedNickname
}
let prefixLength = min(4, peerID.id.count)
let prefix = String(peerID.id.prefix(prefixLength))
return prefix.starts(with: "anon") ? "peer\(prefix)" : "anon\(prefix)"
}
@MainActor
func getMyFingerprint() -> String {
context.noiseIdentityFingerprint()
}
@MainActor
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
switch context.activeChannel {
case .location:
if nickname.contains("#"),
let person = context.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
let base = nickname
.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
.first
.map(String.init)?
.lowercased() ?? nickname.lowercased()
if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let conversationKey = PeerID(nostr_: pubkey)
context.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
case .mesh:
break
}
return context.unifiedPeerID(forNickname: nickname)
}
@MainActor
func nicknameForPeer(_ peerID: PeerID) -> String {
if let name = context.peerNickname(for: peerID) {
return name
}
if let favorite = context.favoriteRelationship(forPeerID: peerID),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID.id),
let favorite = context.favoriteRelationship(forNoiseKey: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
return "user"
}
}
private extension ChatPeerIdentityCoordinator {
@MainActor
func currentPeerID(forFingerprint fingerprint: String) -> PeerID? {
for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint {
return peerID
}
return nil
}
@MainActor
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
// The store migration dedups by message ID, preserves timestamp
// order, carries the unread flag, and removes the old chat.
context.migratePrivateChat(from: oldPeerID, to: newPeerID)
}
@MainActor
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
// Capture before the migration: the store hands its selection off to
// `newPeerID` during `migrateChatState`, and the manager's selection
// mirrors the store, so the old peer ID is no longer selected after.
let wasSelected = context.selectedPrivateChatPeer == oldPeerID
if wasSelected {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if !context.privateMessages(for: oldPeerID).isEmpty {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
}
migrateChatState(from: oldPeerID, to: newPeerID)
if wasSelected {
context.selectedPrivateChatPeer = newPeerID
}
if let fingerprint = context.migrateFingerprintMapping(
from: oldPeerID,
to: newPeerID,
fallback: getFingerprint(for: newPeerID)
) {
if context.selectedPrivateChatPeer == newPeerID {
context.selectedPrivateChatFingerprint = fingerprint
}
}
}
@MainActor
func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String {
if let nickname = context.peerNickname(for: peerID) {
return nickname
}
if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) {
return favorite.peerNickname
}
return "Unknown"
}
@MainActor
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let fingerprint = getFingerprint(for: peerID),
context.isVerifiedFingerprint(fingerprint) {
return .noiseVerified
}
return .noiseSecured
}
@MainActor
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) {
context.unifiedToggleFavorite(ephemeralID)
context.notifyUIChanged()
return
}
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
fallbackNickname: fallbackNickname,
bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey)
)
switch plan.persistenceAction {
case .add(let nickname, let nostrKey):
context.addFavorite(
noiseKey: noisePublicKey,
nostrPublicKey: nostrKey,
nickname: nickname
)
case .remove:
context.removeFavorite(noiseKey: noisePublicKey)
}
context.notifyUIChanged()
if case .send(let isFavorite) = plan.notification {
context.sendFavoriteNotificationViaNostr(
noisePublicKey: noisePublicKey,
isFavorite: isFavorite
)
}
}
}
/// Default for conforming test contexts that model chats as a dictionary;
/// `ChatViewModel` overrides with a store-direct lookup.
extension ChatPeerIdentityContext {
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
}

Some files were not shown because too many files have changed in this diff Show More