Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20255c9916 | ||
|
|
02aa1d67db | ||
|
|
e0e90af9fd | ||
|
|
94e7f91b7b | ||
|
|
00c7f0460a | ||
|
|
175da268dd | ||
|
|
f6f7fa704a | ||
|
|
4f5d25808f | ||
|
|
a05c583fb5 | ||
|
|
25efa62587 | ||
|
|
0d93ce0874 | ||
|
|
20cdd6653a | ||
|
|
862484c0d4 | ||
|
|
e1e481a1a7 | ||
|
|
aed3e6a273 | ||
|
|
b481a70458 | ||
|
|
2b7fd2002b | ||
|
|
ccccb2dd8c | ||
|
|
385e9e2aab | ||
|
|
48035872e1 | ||
|
|
d4594b9504 | ||
|
|
9ae440be6a | ||
|
|
fd2dffdda7 | ||
|
|
953d24f7f2 | ||
|
|
6dd8dd055a | ||
|
|
85112d809b | ||
|
|
e4b3cff5fa | ||
|
|
fb80403cd4 | ||
|
|
7ad19ab4c3 | ||
|
|
b1ff5e6b82 | ||
|
|
ee148a018b | ||
|
|
26b247c329 | ||
|
|
81168adad1 | ||
|
|
18dcc747d7 | ||
|
|
6056d20a13 | ||
|
|
112c0ec1ed | ||
|
|
688b954fb8 | ||
|
|
f5dde45c18 |
@@ -1,228 +1,42 @@
|
||||
name: Propose GeoRelay Data Update
|
||||
name: Fetch GeoRelays Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 0"
|
||||
- cron: '0 6 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
# Default to read-only. The publishing job receives only the scopes required
|
||||
# to push its branch and publish either a PR or a tracking issue.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: georelay-data-update
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
|
||||
UPDATE_BRANCH: automation/georelay-data
|
||||
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
propose-relay-data:
|
||||
name: Validate and propose relay data
|
||||
update-relay-data:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout reviewed base
|
||||
# Pinned actions/checkout v5 so a mutable action tag cannot change the
|
||||
# code that receives this job's write-capable token.
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
# Do not expose the write token to fetch/validation subprocesses.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test GeoRelay validator
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Fetch candidate over pinned HTTPS policy
|
||||
id: upstream
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
||||
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Could not resolve an immutable upstream commit"
|
||||
exit 1
|
||||
fi
|
||||
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
|
||||
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
|
||||
if [[ "$effective_url" != "$source_url" ]]; then
|
||||
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
|
||||
exit 1
|
||||
fi
|
||||
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate candidate against reviewed baseline
|
||||
id: validation
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check for a reviewed-file change
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- relays/online_relays_gps.csv
|
||||
fi
|
||||
|
||||
- name: Push automation branch and publish review request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
|
||||
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
|
||||
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
|
||||
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
|
||||
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Scope credential exposure to this final publishing step.
|
||||
gh auth setup-git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git switch -C "$UPDATE_BRANCH"
|
||||
git add -- relays/online_relays_gps.csv
|
||||
git diff --cached --quiet && {
|
||||
echo "::error::Expected a staged GeoRelay data change"
|
||||
exit 1
|
||||
}
|
||||
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
|
||||
|
||||
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
|
||||
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
|
||||
remote_sha=$(git rev-parse "$remote_ref")
|
||||
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
||||
else
|
||||
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
||||
fi
|
||||
|
||||
body_file="$RUNNER_TEMP/georelay-pr-body.md"
|
||||
{
|
||||
echo "## Automated GeoRelay data proposal"
|
||||
echo
|
||||
echo "- Source: $SOURCE_URL"
|
||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
||||
echo "- Data rows: $DATA_ROWS"
|
||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
||||
echo "- SHA-256: $DATA_SHA256"
|
||||
echo
|
||||
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
|
||||
echo
|
||||
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
|
||||
} > "$body_file"
|
||||
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
||||
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
|
||||
pr_url=""
|
||||
if [[ -n "$existing_pr" ]]; then
|
||||
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
|
||||
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
fi
|
||||
else
|
||||
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
|
||||
pr_url="$created_pr_url"
|
||||
fi
|
||||
fi
|
||||
|
||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
||||
tracking_issues=()
|
||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
||||
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
|
||||
fi
|
||||
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
for issue_number in "${tracking_issues[@]}"; do
|
||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
|
||||
done
|
||||
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
|
||||
if [[ -s "$pr_error" ]]; then
|
||||
cat "$pr_error" >&2
|
||||
fi
|
||||
|
||||
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
|
||||
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
|
||||
{
|
||||
echo "## Validated GeoRelay update awaiting review"
|
||||
echo
|
||||
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
|
||||
echo
|
||||
echo "- Compare and create PR: $compare_url"
|
||||
echo "- Automation branch: $UPDATE_BRANCH"
|
||||
echo "- Source: $SOURCE_URL"
|
||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
||||
echo "- Data rows: $DATA_ROWS"
|
||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
||||
echo "- SHA-256: $DATA_SHA256"
|
||||
echo
|
||||
echo "The snapshot passed the repository's strict validator before the branch was pushed."
|
||||
} > "$issue_body_file"
|
||||
|
||||
if (( ${#tracking_issues[@]} > 0 )); then
|
||||
primary_issue="${tracking_issues[0]}"
|
||||
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
|
||||
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
for duplicate_issue in "${tracking_issues[@]:1}"; do
|
||||
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
|
||||
done
|
||||
else
|
||||
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
|
||||
fi
|
||||
|
||||
# Do not claim success until the fallback issue was confirmed.
|
||||
[[ -n "$issue_url" ]]
|
||||
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Clean obsolete automation review state
|
||||
if: steps.changes.outputs.changed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh auth setup-git
|
||||
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
||||
if [[ -n "$existing_pr" ]]; then
|
||||
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
|
||||
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
||||
while IFS= read -r issue_number; do
|
||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
|
||||
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
|
||||
done <<< "$tracking_issue_numbers"
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
|
||||
git push origin --delete "$UPDATE_BRANCH"
|
||||
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
ls_remote_status=$?
|
||||
if (( ls_remote_status != 2 )); then
|
||||
echo "::error::Could not inspect the obsolete automation branch"
|
||||
exit "$ls_remote_status"
|
||||
fi
|
||||
fi
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Dead Code
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
periphery:
|
||||
name: Periphery scan
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Periphery
|
||||
# homebrew-core formula; the peripheryapp tap lags years behind.
|
||||
run: brew install periphery
|
||||
|
||||
- name: Scan for dead code
|
||||
# Config comes from .periphery.yml; known findings (mostly iOS-only
|
||||
# code invisible to a macOS scan) are suppressed by the committed
|
||||
# baseline. --strict fails the step when NEW dead code appears.
|
||||
run: periphery scan --strict --disable-update-check
|
||||
@@ -94,24 +94,6 @@ jobs:
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
|
||||
# Read coverage before the serial benchmark command below rebuilds the
|
||||
# test binary without instrumentation. Reporting against that newer
|
||||
# binary makes llvm-cov reject the profile as out of date.
|
||||
# Informational only: there is deliberately no percentage threshold, but
|
||||
# a broken/missing report is a CI configuration error and must be visible.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
|
||||
echo "::error::Coverage profile or test binary is missing"
|
||||
exit 1
|
||||
fi
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
|
||||
|
||||
# Benchmarks run serially on an otherwise idle runner for stable
|
||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||
- name: Run performance benchmarks (serial)
|
||||
@@ -133,10 +115,26 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# SPM tests do not link the shipping app targets. This job covers the
|
||||
# iOS-conditional paths and both universal Release link configurations.
|
||||
# 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 Release apps (universal)
|
||||
name: Build iOS app (simulator)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -144,82 +142,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check clean recipe safety
|
||||
run: bash scripts/check-just-clean-safety.sh
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# Build both simulator architectures so CI validates every vendored
|
||||
# Arti simulator slice and the configuration that ships.
|
||||
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-configuration Release \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
ARCHS='arm64 x86_64' \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
ARCHS=arm64 \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
- name: Build macOS (universal, no signing)
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (macOS)" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=macOS' \
|
||||
ARCHS='arm64 x86_64' \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
|
||||
# conditional tests. Build the shared iOS test target and run it on the first
|
||||
# available iPhone simulator from the runner image instead of hard-coding a
|
||||
# model that changes when GitHub updates Xcode. The suite intentionally runs
|
||||
# in one test runner: a number of integration tests exercise process-global
|
||||
# stores and notification centers, so overlapping workers can corrupt each
|
||||
# other's fixtures and turn sub-second tests into multi-minute timeouts.
|
||||
ios-tests:
|
||||
name: Run iOS simulator tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Select available iPhone simulator
|
||||
id: destination
|
||||
run: |
|
||||
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
|
||||
destination_id=$(awk -F'id:' '
|
||||
/platform:iOS Simulator/ && /name:iPhone/ && !found {
|
||||
value=$2
|
||||
sub(/,.*/, "", value)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
print value
|
||||
found=1
|
||||
}
|
||||
' <<< "$destinations")
|
||||
if [ -z "$destination_id" ]; then
|
||||
echo "::error::No available iPhone simulator destination found"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run iOS tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
|
||||
-parallel-testing-enabled NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
|
||||
@@ -80,4 +80,3 @@ build.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
*.profraw
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
@@ -1,21 +0,0 @@
|
||||
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
|
||||
#
|
||||
# CI runs the macOS scheme only (an iOS scan needs a device destination and
|
||||
# doubles the build time). macOS-only scans falsely flag iOS-only code —
|
||||
# state restoration, screenshot handlers, background BLE sampling — so those
|
||||
# findings live in .periphery.baseline.json rather than being "fixed".
|
||||
# When auditing by hand, scan BOTH schemes and intersect:
|
||||
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
|
||||
project: bitchat.xcodeproj
|
||||
schemes:
|
||||
- bitchat (macOS)
|
||||
retain_swift_ui_previews: true
|
||||
# Codable properties are (de)serialized via synthesized conformances the
|
||||
# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle
|
||||
# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk —
|
||||
# and slipped past its baselined USR. Retaining Codable properties outright
|
||||
# is deterministic; a truly-dead Codable field is a persisted-format change
|
||||
# anyway, never a safe mechanical delete.
|
||||
retain_codable_properties: true
|
||||
relative_results: true
|
||||
baseline: .periphery.baseline.json
|
||||
@@ -2,7 +2,6 @@
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .claude
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
|
||||
@@ -3,6 +3,3 @@ DEVELOPMENT_TEAM = ABC123
|
||||
|
||||
// Unique bundle id to be able to register and run locally
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
|
||||
// App and share extension must use an App Group registered to your team.
|
||||
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.7.1
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
@@ -1,66 +1,107 @@
|
||||
# BitChat developer commands
|
||||
#
|
||||
# Builds use a repository-local, ignored DerivedData directory. No recipe
|
||||
# patches, restores, or removes tracked project/configuration files.
|
||||
|
||||
project := "bitchat.xcodeproj"
|
||||
macos_scheme := "bitchat (macOS)"
|
||||
ios_scheme := "bitchat (iOS)"
|
||||
derived_data := ".DerivedData"
|
||||
# BitChat macOS Build Justfile
|
||||
# Handles temporary modifications needed to build and run on macOS
|
||||
|
||||
# Default recipe - shows available commands
|
||||
default:
|
||||
@echo "BitChat developer commands:"
|
||||
@echo " just run Build and run the macOS app"
|
||||
@echo " just build Build the macOS app without signing"
|
||||
@echo " just test Run the SwiftPM test suite"
|
||||
@echo " just test-ios Run tests on the iPhone 17 simulator"
|
||||
@echo " just clean Remove repo-local build artifacts only"
|
||||
@echo " just nuke Also remove nested package build caches"
|
||||
@echo " just check Validate the development environment"
|
||||
@echo "BitChat macOS Build Commands:"
|
||||
@echo " just run - Build and run the macOS app"
|
||||
@echo " just build - Build the macOS app only"
|
||||
@echo " just clean - Clean build artifacts and restore original files"
|
||||
@echo " just check - Check prerequisites"
|
||||
@echo ""
|
||||
@echo "Original files are preserved - modifications are temporary for builds only"
|
||||
|
||||
# Static guard against reintroducing source-restoring or source-deleting clean
|
||||
# behavior. CI runs the same script directly.
|
||||
check-clean-safety:
|
||||
@bash scripts/check-just-clean-safety.sh
|
||||
|
||||
check: check-clean-safety
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
|
||||
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
|
||||
@xcodebuild -version
|
||||
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
build: check
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
# Restore original files
|
||||
restore:
|
||||
@echo "Restoring original project configuration..."
|
||||
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
|
||||
@# Restore iOS-specific files
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@# Use git to restore all modified files except Justfile
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
|
||||
@# Remove any backup files
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
|
||||
|
||||
# Apply macOS-specific modifications
|
||||
patch-for-macos: backup
|
||||
@echo "Temporarily hiding iOS-specific files for macOS build..."
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
|
||||
@echo "Launching BitChat..."
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Backward-compatible alias for the old quick-run recipe.
|
||||
dev-run: run
|
||||
# Clean build artifacts and restore original files
|
||||
clean: restore
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@# Only remove the generated project if we have a backup, otherwise use git
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
|
||||
rm -rf bitchat.xcodeproj; \
|
||||
else \
|
||||
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
|
||||
fi
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@echo "✅ Cleaned and restored original files"
|
||||
|
||||
test:
|
||||
@swift test
|
||||
|
||||
test-ios: check
|
||||
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
|
||||
|
||||
# Artifact-only cleanup. In particular, this recipe never invokes Git and
|
||||
# never writes, moves, restores, or removes source/configuration files.
|
||||
clean:
|
||||
@echo "Cleaning repo-local build artifacts..."
|
||||
@rm -rf -- "{{derived_data}}" ".build"
|
||||
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
|
||||
|
||||
# Retain the familiar command, but keep it artifact-only as well.
|
||||
nuke: clean
|
||||
@echo "Cleaning nested package build caches..."
|
||||
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
|
||||
@rm -rf -- ".cache"
|
||||
@echo "✅ Removed repository build caches; tracked files were untouched"
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
info:
|
||||
@echo "BitChat - decentralized mesh messaging"
|
||||
@echo "macOS 13+ and iOS 16+"
|
||||
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
|
||||
@echo "BitChat - Decentralized Mesh Messaging"
|
||||
@echo "======================================"
|
||||
@echo "• Native macOS SwiftUI app"
|
||||
@echo "• Bluetooth LE mesh networking"
|
||||
@echo "• End-to-end encryption"
|
||||
@echo "• No internet required"
|
||||
@echo "• Works offline with nearby devices"
|
||||
@echo ""
|
||||
@echo "Requirements:"
|
||||
@echo "• macOS 13.0+ (Ventura)"
|
||||
@echo "• Bluetooth LE capable Mac"
|
||||
@echo "• Physical device (no simulator support)"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo "• Set nickname and start chatting"
|
||||
@echo "• Use /join #channel for group chats"
|
||||
@echo "• Use /msg @user for private messages"
|
||||
@echo "• Triple-tap logo for emergency wipe"
|
||||
|
||||
# Force clean everything (nuclear option)
|
||||
nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
@@ -1,155 +1,165 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: July 2026*
|
||||
*Last updated: June 2026*
|
||||
|
||||
## Our Commitment
|
||||
|
||||
bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain.
|
||||
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
|
||||
|
||||
## Summary
|
||||
|
||||
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays.
|
||||
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK.
|
||||
- **No sale of data** — the project does not sell user data or build advertising profiles.
|
||||
- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code.
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||
- **Open source** - You can verify these claims by reading our code
|
||||
|
||||
## What bitchat Stores on Your Device
|
||||
## What Information bitchat Stores
|
||||
|
||||
1. **Identity and cryptographic keys**
|
||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
||||
### On Your Device Only
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
|
||||
1. **Identity Keys**
|
||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
||||
- 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
|
||||
|
||||
3. **Private group state**
|
||||
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||
- Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app.
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
- Stored only on your device
|
||||
- Shared with peers you communicate with
|
||||
|
||||
4. **Queued and carried private messages**
|
||||
- An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain.
|
||||
- A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content.
|
||||
- A panic wipe deletes both stores.
|
||||
3. **Message History** (if enabled)
|
||||
- When room owners enable retention, messages are saved locally
|
||||
- Stored encrypted on your device
|
||||
- You can delete this at any time
|
||||
|
||||
5. **Recent public mesh messages and notices**
|
||||
- Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch.
|
||||
- Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable.
|
||||
- These items are public to the mesh or board where they are posted; they are not confidential messages.
|
||||
4. **Favorite Peers**
|
||||
- Public keys of peers you mark as favorites
|
||||
- Stored only on your device
|
||||
- Allows you to recognize these peers in future sessions
|
||||
|
||||
6. **Media attachments**
|
||||
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||
- Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk.
|
||||
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
|
||||
|
||||
7. **Optional location-channel state**
|
||||
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
|
||||
- Per-geohash Nostr identities are derived locally from a device seed stored in the keychain.
|
||||
- bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages.
|
||||
### Temporary Session Data
|
||||
|
||||
## 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
|
||||
|
||||
While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above.
|
||||
## What Information is Shared
|
||||
|
||||
## What Is Shared
|
||||
### With Other bitchat Users
|
||||
|
||||
### With Nearby Mesh Users
|
||||
When you use bitchat, nearby peers can see:
|
||||
- Your chosen nickname
|
||||
- Your ephemeral public key (changes each session)
|
||||
- Messages you send to public rooms or directly to them
|
||||
- Your approximate Bluetooth signal strength (for connection quality)
|
||||
|
||||
Depending on the feature you use, nearby peers can receive:
|
||||
### With Room Members
|
||||
|
||||
- Your chosen nickname and public Noise/signing identity material.
|
||||
- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell.
|
||||
- Public mesh messages, public notices, and group-control packets you intentionally send.
|
||||
- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry.
|
||||
- Radio metadata available to the receiver, such as approximate Bluetooth signal strength.
|
||||
When you join a password-protected room:
|
||||
- Your messages are visible to others with the password
|
||||
- Your nickname appears in the member list
|
||||
- Room owners can see you've joined
|
||||
|
||||
Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state.
|
||||
### With Nostr Relays (Optional Features)
|
||||
|
||||
### With Private Group Members
|
||||
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.
|
||||
|
||||
Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members.
|
||||
## What We DON'T Do
|
||||
|
||||
### With Nostr Relays and Internet Gateways
|
||||
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
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
|
||||
Internet-backed features are optional. When enabled or used:
|
||||
## Encryption
|
||||
|
||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||
- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices.
|
||||
All private messages use end-to-end encryption:
|
||||
- **X25519** for key exchange
|
||||
- **AES-256-GCM** for message encryption
|
||||
- **Ed25519** for digital signatures
|
||||
- **Argon2id** for password-protected rooms
|
||||
|
||||
Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy.
|
||||
## Your Rights
|
||||
|
||||
## Location and Apple Services
|
||||
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
|
||||
|
||||
Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels.
|
||||
## Bluetooth & Permissions
|
||||
|
||||
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat.
|
||||
- A selected geohash can still reveal an approximate area to peers and relays.
|
||||
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms.
|
||||
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app.
|
||||
bitchat requires Bluetooth permission to function:
|
||||
- Used only for peer-to-peer communication
|
||||
- Bluetooth is not used for tracking
|
||||
- You can revoke this permission at any time in system settings
|
||||
|
||||
## Microphone, Camera, and Media Permissions
|
||||
## Location Permission
|
||||
|
||||
- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below.
|
||||
- Voice-note and live-audio files can remain in Application Support under the media retention rules above.
|
||||
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send.
|
||||
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive.
|
||||
|
||||
## Cryptography
|
||||
|
||||
Private and public features use different protections:
|
||||
|
||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||
|
||||
No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it.
|
||||
|
||||
## Data Retention Summary
|
||||
|
||||
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
|
||||
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
|
||||
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
|
||||
- **Recent public mesh gossip:** up to 15 minutes.
|
||||
- **Public board posts and tombstones:** until expiry, at most seven days.
|
||||
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
|
||||
- **Nostr data:** according to the policies of the relays that receive it.
|
||||
|
||||
## Your Controls
|
||||
|
||||
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||
- **No account:** The project operates no account record for you to request or export.
|
||||
|
||||
## What the Project Does Not Do
|
||||
|
||||
bitchat does not:
|
||||
|
||||
- Operate an account database or project-owned messaging backend.
|
||||
- Include advertising, analytics, or tracking SDKs.
|
||||
- Sell user data or create advertising profiles.
|
||||
- Include exact GPS coordinates in bitchat mesh or Nostr message payloads.
|
||||
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
|
||||
|
||||
The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed.
|
||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||
|
||||
## Data Retention
|
||||
|
||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||
- **Identity Key**: Persists until you delete the app
|
||||
- **Favorites**: Persist until you remove them or delete the app
|
||||
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
|
||||
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
|
||||
- **Everything Else**: Exists only during active sessions
|
||||
|
||||
## Security Measures
|
||||
|
||||
- All communication is encrypted
|
||||
- No accounts or company servers
|
||||
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device.
|
||||
If we update this policy:
|
||||
- The "Last updated" date will change
|
||||
- The updated policy will be included in the app
|
||||
- No retroactive changes can make us collect data already held only in your app
|
||||
|
||||
## Contact
|
||||
|
||||
bitchat is an open source project. For privacy questions:
|
||||
- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main)
|
||||
- Open an issue on GitHub
|
||||
- Join the discussion in public rooms
|
||||
|
||||
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
|
||||
- Open an issue on GitHub.
|
||||
## Philosophy
|
||||
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
*This policy is released into the public domain under The Unlicense, like the project itself.*
|
||||
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
|
||||
|
||||
@@ -93,62 +93,30 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
### Option 1: Using Xcode
|
||||
|
||||
```bash
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
```bash
|
||||
cd bitchat
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
|
||||
For a signed device build, create your ignored local configuration and replace
|
||||
the example team ID with your Apple Developer Team ID:
|
||||
|
||||
```bash
|
||||
cp Configs/Local.xcconfig.example Configs/Local.xcconfig
|
||||
```
|
||||
|
||||
`Local.xcconfig.example` derives unique app and App Group identifiers from that
|
||||
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
|
||||
project or entitlement files do not need to be edited.
|
||||
|
||||
Useful command-line checks from the repository root:
|
||||
|
||||
```bash
|
||||
# macOS Debug build without signing
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
|
||||
-configuration Debug CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
# Full SwiftPM test suite
|
||||
swift test
|
||||
|
||||
# iOS simulator tests
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' test
|
||||
```
|
||||
|
||||
If `iPhone 17` is unavailable, choose an installed simulator from:
|
||||
|
||||
```bash
|
||||
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
|
||||
```
|
||||
To run on a device there're a few steps to prepare the code:
|
||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||
- Entitlements need to be updated manually (TODO: Automate):
|
||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||
|
||||
### Option 2: Using `just`
|
||||
|
||||
```bash
|
||||
brew install just
|
||||
just check
|
||||
just run
|
||||
```
|
||||
```bash
|
||||
brew install just
|
||||
```
|
||||
|
||||
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep
|
||||
Xcode output in the ignored `.DerivedData/` directory. They never patch source,
|
||||
project, configuration, or entitlement files.
|
||||
|
||||
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
|
||||
or restore tracked files, so uncommitted work is preserved. `just test` runs the
|
||||
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- App localizations live in `bitchat/Localizable.xcstrings`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Services/SharedContentHandoff.swift,
|
||||
Services/TransportConfig.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
@@ -93,8 +92,7 @@
|
||||
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
bitchatShareExtension.entitlements,
|
||||
ShareViewController.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
@@ -260,13 +258,9 @@
|
||||
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
|
||||
buildPhases = (
|
||||
0A08E70F08F55FD5BA8C7EF3 /* Sources */,
|
||||
7E9B64F63F93443FB7BA12DF /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||
);
|
||||
name = bitchatShareExtension;
|
||||
productName = bitchatShareExtension;
|
||||
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
|
||||
@@ -338,7 +332,6 @@
|
||||
es,
|
||||
ar,
|
||||
de,
|
||||
fa,
|
||||
fr,
|
||||
he,
|
||||
id,
|
||||
@@ -395,13 +388,6 @@
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
||||
@@ -2,6 +2,11 @@ import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
@@ -20,7 +25,7 @@ enum AppEvent: Sendable, Equatable {
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentReadyForReview(SharedContentKind)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
@@ -31,12 +36,34 @@ enum AppEvent: Sendable, Equatable {
|
||||
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
|
||||
|
||||
@@ -10,32 +10,19 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showingFingerprintFor: PeerID?
|
||||
@Published var isAppInfoPresented = false
|
||||
@Published var isLocationChannelsSheetPresented = false
|
||||
@Published var isNoticesSheetPresented = false
|
||||
/// When the sheet is opened for "notes left here" (empty mesh timeline),
|
||||
/// it should land on the geo tab instead of the channel-derived default.
|
||||
@Published var noticesSheetPrefersGeoTab = false
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let onPanicWipe: () -> Void
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// The composer owns capture state above ChatViewModel. ContentView
|
||||
/// installs this hook so both panic entry points synchronously stop it.
|
||||
private var prepareForPanic: (@MainActor () -> Void)?
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateInboxModel: PrivateInboxModel,
|
||||
onPanicWipe: @escaping () -> Void = {}
|
||||
) {
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.onPanicWipe = onPanicWipe
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
@@ -75,11 +62,6 @@ final class AppChromeModel: ObservableObject {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
func presentNotices(geoTab: Bool = false) {
|
||||
noticesSheetPrefersGeoTab = geoTab
|
||||
isNoticesSheetPresented = true
|
||||
}
|
||||
|
||||
/// Builds the mesh topology map model from the transport's gossiped
|
||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||
@@ -106,13 +88,7 @@ final class AppChromeModel: ObservableObject {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||
prepareForPanic = preparation
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
prepareForPanic?()
|
||||
onPanicWipe()
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ final class AppRuntime: ObservableObject {
|
||||
/// (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
|
||||
@@ -26,8 +28,6 @@ final class AppRuntime: ObservableObject {
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
let sharedContentImportModel: SharedContentImportModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -41,9 +41,8 @@ final class AppRuntime: ObservableObject {
|
||||
#endif
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
|
||||
sharedContentStore: SharedContentStore? = nil
|
||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let conversations = ConversationStore()
|
||||
@@ -51,6 +50,8 @@ final class AppRuntime: ObservableObject {
|
||||
let locationPresenceStore = LocationPresenceStore()
|
||||
let locationManager = LocationChannelManager.shared
|
||||
self.conversations = conversations
|
||||
self.peerIdentityStore = peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore
|
||||
self.chatViewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
@@ -86,48 +87,17 @@ final class AppRuntime: ObservableObject {
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
let resolvedSharedContentStore: SharedContentStore?
|
||||
if let sharedContentStore {
|
||||
resolvedSharedContentStore = sharedContentStore
|
||||
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
|
||||
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
|
||||
} else {
|
||||
resolvedSharedContentStore = nil
|
||||
}
|
||||
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
|
||||
self.sharedContentImportModel = sharedContentImportModel
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel,
|
||||
onPanicWipe: { sharedContentImportModel.discardAll() }
|
||||
privateInboxModel: self.privateInboxModel
|
||||
)
|
||||
let chatViewModel = self.chatViewModel
|
||||
self.boardAlertsModel = BoardAlertsModel(
|
||||
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
||||
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
||||
dependencies: BoardAlertsModel.Dependencies(
|
||||
isOwnPost: { post in
|
||||
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
||||
return !key.isEmpty && key == post.authorSigningKey
|
||||
},
|
||||
emitSystemLine: { content, geohash in
|
||||
if geohash.isEmpty {
|
||||
chatViewModel.addMeshOnlySystemMessage(content)
|
||||
} else {
|
||||
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
if chatViewModel.networkActivationAllowed {
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
guard !started else {
|
||||
checkForSharedContent()
|
||||
return
|
||||
@@ -166,14 +136,12 @@ final class AppRuntime: ObservableObject {
|
||||
}
|
||||
|
||||
func handleDidBecomeActiveNotification() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func handleMacDidBecomeActiveNotification() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
@@ -192,7 +160,6 @@ final class AppRuntime: ObservableObject {
|
||||
didEnterBackground = true
|
||||
|
||||
case .active:
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
@@ -235,17 +202,7 @@ final class AppRuntime: ObservableObject {
|
||||
chatViewModel.applicationWillTerminate()
|
||||
}
|
||||
|
||||
func handleNotificationResponse(
|
||||
identifier: String,
|
||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
if actionIdentifier == NotificationService.waveActionID {
|
||||
chatViewModel.sendMeshWave()
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -292,8 +249,6 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.willRestart))
|
||||
self?.chatViewModel.handleTorWillRestart()
|
||||
}
|
||||
@@ -302,8 +257,6 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||
self?.chatViewModel.handleTorDidBecomeReady()
|
||||
}
|
||||
@@ -312,8 +265,6 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.willStart))
|
||||
self?.chatViewModel.handleTorWillStart()
|
||||
}
|
||||
@@ -322,8 +273,6 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||
}
|
||||
@@ -340,22 +289,36 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
let previousID = sharedContentImportModel.offer?.id
|
||||
guard let payload = sharedContentImportModel.refresh(
|
||||
destination: currentSharedContentDestination
|
||||
) else { return }
|
||||
|
||||
if previousID != payload.id {
|
||||
record(.sharedContentReadyForReview(payload.kind))
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var currentSharedContentDestination: SharedContentDestination {
|
||||
SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
|
||||
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||
activeChannel: locationChannelsModel.selectedChannel
|
||||
)
|
||||
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) {
|
||||
@@ -364,9 +327,7 @@ private extension AppRuntime {
|
||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||
lastNostrRelayConnectedState = isConnected
|
||||
|
||||
guard chatViewModel.networkActivationAllowed,
|
||||
started,
|
||||
becameConnected else { return }
|
||||
guard started, becameConnected else { return }
|
||||
|
||||
let isInitialConnection = !didHandleInitialNostrConnection
|
||||
didHandleInitialNostrConnection = true
|
||||
|
||||
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||
/// instead of rewriting every surviving dictionary entry. This matters
|
||||
/// after the 1337-message cap is reached, when every steady-state tail
|
||||
/// append evicts one old row.
|
||||
///
|
||||
/// Out-of-order inserts and middle removals still reindex only the
|
||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||
/// 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] = [:]
|
||||
private var indexOffset = 0
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// 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 = physicalIndex(forMessageID: message.id) {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// `.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 = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// 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 = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
if index == 0 {
|
||||
indexOffset += 1
|
||||
} else {
|
||||
reindex(from: index)
|
||||
}
|
||||
reindex(from: index)
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
indexOffset = 0
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
indexOffset = 0
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
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 logicalIndex = indexByMessageID[message.id] {
|
||||
let expectedIndex = indexOffset + position
|
||||
if logicalIndex != expectedIndex {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||
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")
|
||||
@@ -234,25 +225,8 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
guard let current else { return false }
|
||||
if current == new { return true }
|
||||
|
||||
// Never downgrade to a weaker delivery state. Ordering of certainty:
|
||||
// sending < sent < carried < delivered < read. A late `.sent` write
|
||||
// (e.g. the optimistic stamp after routing) must not clobber the
|
||||
// `.carried` the router already set when it handed a copy to a
|
||||
// courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous
|
||||
// failure is weaker than a confirmed recipient receipt too, so it may
|
||||
// not replace `.delivered`/`.read`. Same for the
|
||||
// `.sending` stamp a pre-handshake resend emits asynchronously: it
|
||||
// can land after the message already reached `.sent`, and "Sent" was
|
||||
// already truthful. (`.failed` → `.sending` stays allowed so a real
|
||||
// failure retry is visible.)
|
||||
switch (current, new) {
|
||||
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed):
|
||||
return true
|
||||
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
|
||||
return true
|
||||
case (.carried, .sent), (.carried, .sending):
|
||||
return true
|
||||
case (.sent, .sending):
|
||||
case (.read, .delivered), (.read, .sent):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -278,17 +252,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = indexOffset + index
|
||||
indexByMessageID[messages[index].id] = index
|
||||
}
|
||||
}
|
||||
|
||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||
let index = logicalIndex - indexOffset
|
||||
guard messages.indices.contains(index) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
@@ -298,7 +265,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
indexOffset += overflow
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
@@ -829,6 +796,16 @@ extension ConversationStore {
|
||||
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
|
||||
@@ -860,8 +837,8 @@ extension Conversation {
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||
indexByMessageID[messages[1].id] = indexOffset
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
@@ -875,8 +852,8 @@ extension Conversation {
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = indexOffset
|
||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,7 +893,7 @@ extension ConversationStore {
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,10 +12,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||
/// Who is talking live in the public mesh channel right now (floor
|
||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||
@Published private(set) var activeLiveVoiceTalker: String?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let privateConversationModel: PrivateConversationModel
|
||||
@@ -154,24 +150,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
|
||||
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||
chatViewModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: approved
|
||||
)
|
||||
}
|
||||
|
||||
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||
/// peer can hear it now, classic voice note otherwise.
|
||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||
chatViewModel.makeVoiceCaptureSession()
|
||||
}
|
||||
|
||||
/// Whether this message is a live voice burst still streaming in.
|
||||
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
|
||||
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
|
||||
}
|
||||
|
||||
func cancelMediaSend(messageID: String) {
|
||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||
}
|
||||
@@ -197,14 +175,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
chatViewModel.$activePublicVoiceTalker
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$activeLiveVoiceTalker)
|
||||
|
||||
chatViewModel.$legacyPrivateMediaConsentRequest
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$legacyPrivateMediaConsentRequest)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@@ -16,6 +17,7 @@ final class LocationChannelsModel: ObservableObject {
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private let gateway: GatewayService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
@@ -100,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func setGatewayEnabled(_ enabled: Bool) {
|
||||
gateway.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
|
||||
@@ -7,146 +7,45 @@ final class LocationPresenceStore: ObservableObject {
|
||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||
@Published private(set) var teleportedGeo: Set<String> = []
|
||||
|
||||
private let teleportedGeoCapacity: Int
|
||||
private var teleportedGeoOrder: [String] = []
|
||||
private let geoNicknameCapacity: Int
|
||||
private var geoNicknameOrder: [String] = []
|
||||
|
||||
init(
|
||||
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
|
||||
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
|
||||
) {
|
||||
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
|
||||
}
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
let normalized = geohash?.lowercased()
|
||||
if currentGeohash != normalized {
|
||||
// Presence markers are scoped to the active geohash channel.
|
||||
clearTeleportedGeo()
|
||||
clearGeoNicknames()
|
||||
}
|
||||
currentGeohash = normalized
|
||||
currentGeohash = geohash?.lowercased()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
}
|
||||
|
||||
let key = pubkeyHex.lowercased()
|
||||
if geoNicknames[key] != nil {
|
||||
geoNicknames[key] = nickname
|
||||
return
|
||||
}
|
||||
|
||||
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||
geoNicknameOrder.removeFirst()
|
||||
geoNicknames.removeValue(forKey: oldest)
|
||||
}
|
||||
|
||||
geoNicknames[key] = nickname
|
||||
geoNicknameOrder.append(key)
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
var normalized: [String: String] = [:]
|
||||
for (key, value) in nicknames {
|
||||
let lower = key.lowercased()
|
||||
guard seen.insert(lower).inserted else { continue }
|
||||
ordered.append(lower)
|
||||
normalized[lower] = value
|
||||
}
|
||||
if ordered.count > geoNicknameCapacity {
|
||||
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||
ordered = kept
|
||||
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
|
||||
normalized[key].map { (key, $0) }
|
||||
})
|
||||
}
|
||||
geoNicknameOrder = ordered
|
||||
geoNicknames = normalized
|
||||
geoNicknames = Dictionary(
|
||||
uniqueKeysWithValues: nicknames.map { key, value in
|
||||
(key.lowercased(), value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
geoNicknames.removeAll()
|
||||
geoNicknameOrder.removeAll()
|
||||
}
|
||||
|
||||
func retainGeoNicknames(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
|
||||
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
let key = pubkeyHex.lowercased()
|
||||
guard !teleportedGeo.contains(key) else { return }
|
||||
|
||||
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||
teleportedGeoOrder.removeFirst()
|
||||
teleportedGeo.remove(oldest)
|
||||
}
|
||||
|
||||
teleportedGeo.insert(key)
|
||||
teleportedGeoOrder.append(key)
|
||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
teleportedGeo.remove(key)
|
||||
teleportedGeoOrder.removeAll { $0 == key }
|
||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
ordered.append(key)
|
||||
}
|
||||
if ordered.count > teleportedGeoCapacity {
|
||||
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||
}
|
||||
teleportedGeoOrder = ordered
|
||||
teleportedGeo = Set(ordered)
|
||||
}
|
||||
|
||||
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
geoNicknameOrder.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
//
|
||||
// NearbyNotesCounter.swift
|
||||
// bitchat
|
||||
//
|
||||
// Counts unexpired location notes left at the user's current building-level
|
||||
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
|
||||
// subscribes while a view holds it active, and only when location notes are
|
||||
// enabled and location permission is already granted (it never prompts).
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NearbyNotesCounter: ObservableObject {
|
||||
static let shared = NearbyNotesCounter()
|
||||
|
||||
@Published private(set) var noteCount = 0
|
||||
/// Whether an explicit notes act (the empty-timeline "check for notes"
|
||||
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
|
||||
/// unlocked the counter this session. Until then nothing subscribes:
|
||||
/// merely looking at the mesh timeline must not open a building-precision
|
||||
/// relay REQ that leaks location passively.
|
||||
@Published private(set) var revealed = false
|
||||
|
||||
private var manager: LocationNotesManager?
|
||||
private var managerCancellable: AnyCancellable?
|
||||
private var channelsCancellable: AnyCancellable?
|
||||
private var permissionCancellable: AnyCancellable?
|
||||
private var settingCancellable: AnyCancellable?
|
||||
private var activeHolders = 0
|
||||
private let locationManager: LocationChannelManager
|
||||
private let managerFactory: @MainActor (String) -> LocationNotesManager
|
||||
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
|
||||
|
||||
init(
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
|
||||
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
|
||||
) {
|
||||
self.locationManager = locationManager
|
||||
self.managerFactory = managerFactory
|
||||
self.releaseManager = releaseManager
|
||||
}
|
||||
|
||||
/// Whether the empty-timeline "check for notes" hint should render.
|
||||
/// The permission gate matters: `retarget()` never subscribes without
|
||||
/// location authorization, so offering the hint to an unauthorized
|
||||
/// install would be a silent dead-end — tap, `revealed` flips, the hint
|
||||
/// vanishes, and nothing else happens for the session. The hint never
|
||||
/// prompts; it simply stays hidden until permission exists. The caller
|
||||
/// passes its own observed permission state so the hint re-renders when
|
||||
/// authorization changes.
|
||||
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
|
||||
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
|
||||
}
|
||||
|
||||
/// Marks the one explicit act that lets the counter subscribe. Sticky for
|
||||
/// the rest of the session (the singleton's lifetime); `deactivate()`
|
||||
/// deliberately does not reset it.
|
||||
func reveal() {
|
||||
guard !revealed else { return }
|
||||
revealed = true
|
||||
retarget()
|
||||
}
|
||||
|
||||
/// Begins (or keeps) the notes subscription for the current building
|
||||
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
|
||||
/// hold it.
|
||||
func activate() {
|
||||
activeHolders += 1
|
||||
guard activeHolders == 1 else { return }
|
||||
channelsCancellable = locationManager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
// CoreLocation can revoke authorization while the view remains
|
||||
// mounted. `availableChannels` deliberately retains its last value,
|
||||
// so permission must be an independent invalidation signal or the
|
||||
// building REQ survives on stale coordinates.
|
||||
permissionCancellable = locationManager.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
// The app-info kill switch must take effect immediately, not on the
|
||||
// next location change or remount.
|
||||
settingCancellable = NotificationCenter.default
|
||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
retarget()
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
activeHolders = max(0, activeHolders - 1)
|
||||
guard activeHolders == 0 else { return }
|
||||
channelsCancellable = nil
|
||||
permissionCancellable = nil
|
||||
settingCancellable = nil
|
||||
managerCancellable = nil
|
||||
releaseManager(manager)
|
||||
manager = nil
|
||||
noteCount = 0
|
||||
}
|
||||
|
||||
private func retarget() {
|
||||
guard activeHolders > 0,
|
||||
revealed,
|
||||
LocationNotesSettings.enabled,
|
||||
locationManager.permissionState == .authorized,
|
||||
let geohash = locationManager.availableChannels
|
||||
.first(where: { $0.level == .building })?.geohash
|
||||
else {
|
||||
managerCancellable = nil
|
||||
releaseManager(manager)
|
||||
manager = nil
|
||||
noteCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
if let manager {
|
||||
guard manager.geohash != geohash.lowercased() else { return }
|
||||
// Pooled managers are shared; never retarget one in place —
|
||||
// release the old cell and acquire the new one.
|
||||
managerCancellable = nil
|
||||
releaseManager(manager)
|
||||
self.manager = nil
|
||||
}
|
||||
|
||||
let fresh = managerFactory(geohash)
|
||||
manager = fresh
|
||||
managerCancellable = fresh.$notes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notes in
|
||||
let now = Date()
|
||||
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ final class PeerIdentityStore: ObservableObject {
|
||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||
}
|
||||
|
||||
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||
stablePeerIDsByShortID = mappings
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
peerFingerprintsByPeerID[peerID]
|
||||
}
|
||||
@@ -90,6 +94,10 @@ final class PeerIdentityStore: ObservableObject {
|
||||
invalidateEncryptionCache(for: peerID)
|
||||
}
|
||||
|
||||
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||
encryptionStatuses = statuses
|
||||
}
|
||||
|
||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||
verifiedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentDestination: Sendable, Equatable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case privateConversation(peerID: PeerID, displayName: String)
|
||||
|
||||
static func resolve(
|
||||
selectedPrivatePeerID: PeerID?,
|
||||
privateDisplayName: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> SharedContentDestination {
|
||||
if let selectedPrivatePeerID {
|
||||
let fallback = String(selectedPrivatePeerID.id.prefix(12))
|
||||
return .privateConversation(
|
||||
peerID: selectedPrivatePeerID,
|
||||
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return .mesh
|
||||
case .location(let channel):
|
||||
return .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .geohash(let geohash):
|
||||
return "#\(geohash)"
|
||||
case .privateConversation(_, let displayName):
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedContentOffer: Identifiable, Sendable, Equatable {
|
||||
let payload: SharedContentPayload
|
||||
let destination: SharedContentDestination
|
||||
|
||||
var id: UUID { payload.id }
|
||||
}
|
||||
|
||||
/// Holds a pending extension handoff until the user chooses a destination and
|
||||
/// explicitly adds it to the composer. This type has no send dependency by
|
||||
/// design: confirming an import can never transmit a message.
|
||||
@MainActor
|
||||
final class SharedContentImportModel: ObservableObject {
|
||||
@Published private(set) var offer: SharedContentOffer?
|
||||
|
||||
private let store: SharedContentStore?
|
||||
|
||||
init(store: SharedContentStore?) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> SharedContentPayload? {
|
||||
guard let payload = store?.pending(now: now) else {
|
||||
offer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
|
||||
if offer != nextOffer {
|
||||
offer = nextOffer
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func updateDestination(_ destination: SharedContentDestination) {
|
||||
guard let offer, offer.destination != destination else { return }
|
||||
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
|
||||
}
|
||||
|
||||
/// Returns composer text only when the currently displayed destination is
|
||||
/// still current and the reviewed envelope is still the stored envelope.
|
||||
/// A destination change updates the prompt and requires another tap.
|
||||
func confirm(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> String? {
|
||||
guard let offer else { return nil }
|
||||
guard offer.destination == destination else {
|
||||
updateDestination(destination)
|
||||
return nil
|
||||
}
|
||||
guard let payload = store?.consume(id: offer.id, now: now) else {
|
||||
_ = refresh(destination: destination, now: now)
|
||||
return nil
|
||||
}
|
||||
|
||||
self.offer = nil
|
||||
return payload.composerText
|
||||
}
|
||||
|
||||
func cancel(destination: SharedContentDestination, now: Date = Date()) {
|
||||
guard let offer else { return }
|
||||
store?.discard(id: offer.id)
|
||||
self.offer = nil
|
||||
// If a newer share replaced the reviewed envelope, surface it rather
|
||||
// than losing it with the older cancellation.
|
||||
_ = refresh(destination: destination, now: now)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
store?.discardAll()
|
||||
offer = nil
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Combine
|
||||
import Foundation
|
||||
|
||||
struct FingerprintPresentationState: Equatable {
|
||||
let statusPeerID: PeerID
|
||||
let peerNickname: String
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let theirFingerprint: String?
|
||||
@@ -55,6 +56,10 @@ final class VerificationModel: ObservableObject {
|
||||
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
|
||||
@@ -105,6 +110,7 @@ final class VerificationModel: ObservableObject {
|
||||
}
|
||||
|
||||
return FingerprintPresentationState(
|
||||
statusPeerID: statusPeerID,
|
||||
peerNickname: peerNickname,
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 460 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -27,66 +27,6 @@
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 505 B |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -8,6 +8,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
#if DEBUG
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
@@ -40,11 +43,14 @@ struct BitchatApp: App {
|
||||
.environmentObject(runtime.locationChannelsModel)
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.environmentObject(runtime.sharedContentImportModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
#if DEBUG
|
||||
// Arm the opt-in UDP log sink from persisted config (no-op
|
||||
// until a collector host is set in the App Info sheet).
|
||||
LogNetworkSink.shared.reloadConfiguration()
|
||||
#endif
|
||||
}
|
||||
.onOpenURL { url in
|
||||
runtime.handleOpenURL(url)
|
||||
@@ -105,20 +111,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let identifier = response.notification.request.identifier
|
||||
let actionIdentifier = response.actionIdentifier
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Complete only after the response is handled: for a background
|
||||
// action (👋 wave) the system may suspend the app the moment the
|
||||
// completion handler runs, which would drop the queued send.
|
||||
Task { @MainActor in
|
||||
self.runtime?.handleNotificationResponse(
|
||||
identifier: identifier,
|
||||
actionIdentifier: actionIdentifier,
|
||||
userInfo: userInfo
|
||||
)
|
||||
completionHandler()
|
||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
//
|
||||
// AudioSessionCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// The raw audio-session calls the coordinator makes, abstracted so the
|
||||
/// state machine is unit-testable with a mock (and compiles on the macOS
|
||||
/// test host, where `AVAudioSession` doesn't exist).
|
||||
///
|
||||
/// Calls arrive on the coordinator's private serial queue — never the main
|
||||
/// thread. `setCategory`/`setActive` block on IPC to the audio server
|
||||
/// (observed >1 s under contention on device, tripping the system gesture
|
||||
/// gate), and Apple explicitly recommends activating the session off the
|
||||
/// main thread.
|
||||
protocol SessionApplying: Sendable {
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws
|
||||
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
|
||||
}
|
||||
|
||||
/// Sole owner of `AVAudioSession` category/activation for voice features.
|
||||
///
|
||||
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
|
||||
/// voice notes) can be live simultaneously; letting each engine configure
|
||||
/// the shared session directly made them stomp each other's category and
|
||||
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
|
||||
/// client acquires a `Token` and the coordinator:
|
||||
///
|
||||
/// - reference-counts activation: `setActive(true)` only on the first
|
||||
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
|
||||
/// last one releases — no client can deactivate another's session;
|
||||
/// - keeps one escalating category: playback-only holders get `.playback`,
|
||||
/// any capture holder escalates to `.playAndRecord`, and the category is
|
||||
/// never downgraded while anyone still holds a token (capture ending must
|
||||
/// not yank the route out from under live playback);
|
||||
/// - fans out `onInterrupted` on system interruptions and when the active
|
||||
/// route's device disappears (no auto-resume: bursts are transient, the
|
||||
/// next press or burst simply re-acquires). The escalating category change
|
||||
/// fans out separately as `onCategoryEscalated` — the session stays live,
|
||||
/// so holders that can rebuild their engine against the new configuration
|
||||
/// keep playing (talk-over is bidirectional); holders that don't provide
|
||||
/// it fall back to `onInterrupted`.
|
||||
///
|
||||
/// Threading: all state lives on a private serial queue, which both
|
||||
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
|
||||
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
|
||||
/// is fire-and-forget onto the queue). Holder callbacks always run on the
|
||||
/// main actor.
|
||||
///
|
||||
/// Microphone *permission* queries stay with their callers; this type owns
|
||||
/// only category and activation.
|
||||
///
|
||||
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
|
||||
final class AudioSessionCoordinator: @unchecked Sendable {
|
||||
enum Use {
|
||||
case playback
|
||||
case capture
|
||||
}
|
||||
|
||||
/// The session category the coordinator has applied (the `SessionApplying`
|
||||
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
|
||||
enum Category {
|
||||
case playback
|
||||
case playAndRecord
|
||||
}
|
||||
|
||||
/// Opaque handle for one client's hold on the session. Release exactly
|
||||
/// once when done (extra releases are ignored).
|
||||
///
|
||||
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
|
||||
/// closures (non-Sendable as stored types). Lifecycle state is protected
|
||||
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
|
||||
final class Token: @unchecked Sendable {
|
||||
fileprivate enum CallbackKind: Sendable {
|
||||
case interrupted
|
||||
case categoryEscalated
|
||||
}
|
||||
|
||||
/// A callback snapshot is only valid for the lifecycle epoch in which
|
||||
/// it was captured. `release` advances the epoch synchronously before
|
||||
/// its queue work, so a callback already headed to the main actor can't
|
||||
/// reach a client that has since released this token and reacquired a
|
||||
/// different one.
|
||||
fileprivate struct CallbackTicket: Sendable {
|
||||
let token: Token
|
||||
let kind: CallbackKind
|
||||
let lifecycleEpoch: UInt64
|
||||
}
|
||||
|
||||
private enum Lifecycle {
|
||||
/// Registered on the session queue, but `acquire` has not yet
|
||||
/// returned into the client's main-actor call frame.
|
||||
case acquiring
|
||||
case ready
|
||||
case released
|
||||
}
|
||||
|
||||
fileprivate let onInterrupted: @MainActor () -> Void
|
||||
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
|
||||
private let stateLock = NSLock()
|
||||
private var lifecycle = Lifecycle.acquiring
|
||||
private var lifecycleEpoch: UInt64 = 0
|
||||
/// A terminal event that lands while the token is registered but not
|
||||
/// yet handed off invalidates the acquire before its caller can start.
|
||||
private var terminalEventPendingHandoff = false
|
||||
|
||||
fileprivate init(
|
||||
onInterrupted: @escaping @MainActor () -> Void,
|
||||
onCategoryEscalated: (@MainActor () -> Void)?
|
||||
) {
|
||||
self.onInterrupted = onInterrupted
|
||||
self.onCategoryEscalated = onCategoryEscalated
|
||||
}
|
||||
|
||||
/// Records an event at the same linearization point at which the
|
||||
/// coordinator snapshots its holders. An acquiring token cannot safely
|
||||
/// receive a callback yet: terminal events invalidate the acquire,
|
||||
/// while category escalation needs no callback because its engine will
|
||||
/// start against the already-escalated configuration.
|
||||
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
|
||||
stateLock.withLock {
|
||||
switch lifecycle {
|
||||
case .acquiring:
|
||||
switch kind {
|
||||
case .interrupted:
|
||||
terminalEventPendingHandoff = true
|
||||
case .categoryEscalated:
|
||||
break
|
||||
}
|
||||
return nil
|
||||
case .ready:
|
||||
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
|
||||
case .released:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes the main-actor ownership handoff if no terminal event
|
||||
/// invalidated it. Because `acquire` itself is main-actor isolated, a
|
||||
/// successful handoff returns directly into the caller without another
|
||||
/// actor hop; no callback can interleave before the caller stores the
|
||||
/// returned token.
|
||||
fileprivate func completeHandoff() -> Bool {
|
||||
stateLock.withLock {
|
||||
guard lifecycle == .acquiring,
|
||||
!terminalEventPendingHandoff
|
||||
else { return false }
|
||||
lifecycle = .ready
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the token dead synchronously, before the asynchronous holder
|
||||
/// removal. Returns false for an already-released token.
|
||||
fileprivate func markReleased() -> Bool {
|
||||
stateLock.withLock {
|
||||
guard lifecycle != .released else { return false }
|
||||
lifecycle = .released
|
||||
lifecycleEpoch &+= 1
|
||||
terminalEventPendingHandoff = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Revalidates a queue snapshot at the main-actor delivery boundary.
|
||||
/// The lock is deliberately released before invoking client code: real
|
||||
/// callbacks commonly call `release` on this same token.
|
||||
@MainActor
|
||||
fileprivate func deliver(_ ticket: CallbackTicket) {
|
||||
let isLive = stateLock.withLock {
|
||||
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
|
||||
}
|
||||
guard isLive else { return }
|
||||
switch ticket.kind {
|
||||
case .interrupted:
|
||||
onInterrupted()
|
||||
case .categoryEscalated:
|
||||
(onCategoryEscalated ?? onInterrupted)()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic suspension points for lifecycle race tests. Production
|
||||
/// instances use the nil defaults; the hooks never move session calls off
|
||||
/// the coordinator queue or callback execution off the main actor.
|
||||
struct TestingHooks: Sendable {
|
||||
let beforeAcquireHandoff: (@Sendable () async -> Void)?
|
||||
let beforeCallbackDelivery: (@Sendable () async -> Void)?
|
||||
|
||||
init(
|
||||
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
|
||||
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
|
||||
) {
|
||||
self.beforeAcquireHandoff = beforeAcquireHandoff
|
||||
self.beforeCallbackDelivery = beforeCallbackDelivery
|
||||
}
|
||||
}
|
||||
|
||||
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
|
||||
|
||||
private let session: SessionApplying
|
||||
private let testingHooks: TestingHooks
|
||||
/// Confines all mutable state, serializes whole acquire/release
|
||||
/// operations (two rapid presses can't interleave their category and
|
||||
/// activation calls), and hosts the blocking session IPC off main.
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
|
||||
|
||||
// Queue-confined state.
|
||||
private var holders: [ObjectIdentifier: Token] = [:]
|
||||
private var currentCategory: Category?
|
||||
private var sessionActive = false
|
||||
/// Written once in init, read in deinit — never touched concurrently.
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
|
||||
self.session = session
|
||||
self.testingHooks = testingHooks
|
||||
observeSystemNotifications()
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures + activates the session for `use` and registers the caller
|
||||
/// as a holder. The blocking `AVAudioSession` calls run on the session
|
||||
/// queue — the caller suspends instead of stalling its thread (a PTT
|
||||
/// press used to block main >1 s in `setActive`, tripping the system
|
||||
/// gesture gate). `onInterrupted` fires (on the main actor) when the
|
||||
/// client must stop using the session: a system interruption began or
|
||||
/// its route's device went away. The client should stop its engine,
|
||||
/// finalize any artifacts, and release — resuming means acquiring again.
|
||||
///
|
||||
/// `onCategoryEscalated` fires instead when the session category
|
||||
/// escalated underneath the holder (a capture client joined): the session
|
||||
/// stays active, so a holder that can rebuild its engine against the new
|
||||
/// configuration should restart and keep going. Holders that pass `nil`
|
||||
/// get `onInterrupted` for escalation too. Escalation is delivered before
|
||||
/// `acquire` returns, so the new holder starts its engine strictly after
|
||||
/// existing ones were told to rebuild. Main-actor isolation is also the
|
||||
/// ownership handoff boundary: if interruption or route loss lands after
|
||||
/// queue registration but before that boundary, the provisional holder is
|
||||
/// removed and `acquire` throws `CancellationError` instead of returning a
|
||||
/// token whose callback already fired.
|
||||
@MainActor
|
||||
func acquire(
|
||||
_ use: Use,
|
||||
onInterrupted: @escaping @MainActor () -> Void,
|
||||
onCategoryEscalated: (@MainActor () -> Void)? = nil
|
||||
) async throws -> Token {
|
||||
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
|
||||
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escalating playback -> playAndRecord reconfigures the hardware
|
||||
// route; engines started against the old configuration must restart.
|
||||
if !reconfigured.isEmpty {
|
||||
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
|
||||
await deliver(reconfigured)
|
||||
}
|
||||
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
|
||||
await beforeAcquireHandoff()
|
||||
}
|
||||
guard token.completeHandoff() else {
|
||||
// A call/Siri interruption or route loss landed after registration
|
||||
// but before ownership handoff. Remove the provisional holder and
|
||||
// fail instead of starting a client engine after the stop event.
|
||||
release(token)
|
||||
throw CancellationError()
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// Drops one holder. Deactivates the session (notifying other apps) only
|
||||
/// when the last holder releases. Safe to call more than once, from any
|
||||
/// thread (including `deinit` paths): the work is fire-and-forget onto
|
||||
/// the session queue, so the blocking deactivation IPC never runs on the
|
||||
/// caller.
|
||||
func release(_ token: Token) {
|
||||
guard token.markReleased() else { return }
|
||||
queue.async {
|
||||
self.releaseOnQueue(token)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue-confined core
|
||||
|
||||
/// Returns callback tickets for pre-existing live holders whose engines
|
||||
/// must restart because this acquire escalated the category.
|
||||
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
|
||||
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
|
||||
let categoryChanged = target != currentCategory
|
||||
let previousCategory = currentCategory
|
||||
if categoryChanged {
|
||||
try session.setCategory(target)
|
||||
currentCategory = target
|
||||
}
|
||||
if !sessionActive {
|
||||
do {
|
||||
try session.setActive(true, notifyOthersOnDeactivation: false)
|
||||
} catch {
|
||||
// Activation failed (e.g. a phone call owns the hardware):
|
||||
// with no holder registered, an escalated category recorded
|
||||
// here would stick and pin later playback-only acquires to
|
||||
// .playAndRecord. Existing holders keep the category the
|
||||
// hardware really has.
|
||||
if categoryChanged, holders.isEmpty {
|
||||
currentCategory = previousCategory
|
||||
}
|
||||
throw error
|
||||
}
|
||||
sessionActive = true
|
||||
}
|
||||
|
||||
let reconfigured = categoryChanged
|
||||
? holders.values.compactMap { $0.record(.categoryEscalated) }
|
||||
: []
|
||||
holders[ObjectIdentifier(token)] = token
|
||||
return reconfigured
|
||||
}
|
||||
|
||||
private func releaseOnQueue(_ token: Token) {
|
||||
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
|
||||
guard holders.isEmpty else { return }
|
||||
currentCategory = nil
|
||||
guard sessionActive else { return }
|
||||
sessionActive = false
|
||||
do {
|
||||
try session.setActive(false, notifyOthersOnDeactivation: true)
|
||||
} catch {
|
||||
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
continuation.resume(returning: body())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliver(_ tickets: [Token.CallbackTicket]) async {
|
||||
guard !tickets.isEmpty else { return }
|
||||
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
|
||||
await beforeCallbackDelivery()
|
||||
}
|
||||
for ticket in tickets {
|
||||
ticket.token.deliver(ticket)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System events (internal so tests can drive them directly)
|
||||
|
||||
/// A system interruption began: the session is already deactivated by the
|
||||
/// OS, so just mark it inactive and tell every ready holder (on the main
|
||||
/// actor) to stop. A provisional acquiring holder is invalidated instead.
|
||||
/// No auto-resume — the next acquire re-activates.
|
||||
func handleInterruptionBegan() async {
|
||||
let tickets = await onQueue { () -> [Token.CallbackTicket] in
|
||||
self.sessionActive = false
|
||||
return self.holders.values.compactMap { $0.record(.interrupted) }
|
||||
}
|
||||
await deliver(tickets)
|
||||
}
|
||||
|
||||
/// The active route's input/output device disappeared (e.g. BT headset
|
||||
/// off): ready holders' engines are wedged against a dead route — stop
|
||||
/// them; invalidate a holder whose acquire has not returned yet.
|
||||
func handleRouteDeviceUnavailable() async {
|
||||
let tickets = await onQueue {
|
||||
self.holders.values.compactMap { $0.record(.interrupted) }
|
||||
}
|
||||
await deliver(tickets)
|
||||
}
|
||||
|
||||
/// Test hook: suspends until every session operation enqueued before this
|
||||
/// call — including fire-and-forget `release`s — has completed.
|
||||
func drain() async {
|
||||
await onQueue {}
|
||||
}
|
||||
|
||||
private func observeSystemNotifications() {
|
||||
#if os(iOS)
|
||||
let center = NotificationCenter.default
|
||||
observers.append(center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
AVAudioSession.InterruptionType(rawValue: raw) == .began,
|
||||
let self
|
||||
else { return }
|
||||
SecureLogger.info("AudioSession: interruption began", category: .session)
|
||||
Task { await self.handleInterruptionBegan() }
|
||||
})
|
||||
observers.append(center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
|
||||
let self
|
||||
else { return }
|
||||
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
|
||||
Task { await self.handleRouteDeviceUnavailable() }
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Production adapter
|
||||
|
||||
#if os(iOS)
|
||||
private struct SystemAudioSession: SessionApplying {
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch category {
|
||||
case .playback:
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
case .playAndRecord:
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
#if targetEnvironment(simulator)
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
active,
|
||||
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// macOS has no app-level audio session; the coordinator still runs its
|
||||
/// bookkeeping so client code is identical across platforms.
|
||||
private struct SystemAudioSession: SessionApplying {
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
|
||||
}
|
||||
#endif
|
||||
@@ -1,175 +0,0 @@
|
||||
//
|
||||
// PTTAudioCodec.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
|
||||
/// carries a bit reservoir across frames); one instance per burst.
|
||||
/// Not thread-safe — confine to one queue.
|
||||
final class PTTFrameEncoder {
|
||||
private let converter: AVAudioConverter
|
||||
private var pendingInput: [AVAudioPCMBuffer] = []
|
||||
|
||||
init?() {
|
||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||
let aac = PTTAudioFormat.aacFormat,
|
||||
let converter = AVAudioConverter(from: pcm, to: aac)
|
||||
else { return nil }
|
||||
converter.bitRate = PTTAudioFormat.bitRate
|
||||
self.converter = converter
|
||||
}
|
||||
|
||||
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
|
||||
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
|
||||
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
|
||||
pendingInput.append(buffer)
|
||||
return drainConverter()
|
||||
}
|
||||
|
||||
private func drainConverter() -> [Data] {
|
||||
var frames: [Data] = []
|
||||
while true {
|
||||
let output = AVAudioCompressedBuffer(
|
||||
format: converter.outputFormat,
|
||||
packetCapacity: 8,
|
||||
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
|
||||
)
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
|
||||
guard let self, let next = self.pendingInput.first else {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
self.pendingInput.removeFirst()
|
||||
outStatus.pointee = .haveData
|
||||
return next
|
||||
}
|
||||
if status == .error {
|
||||
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||
return frames
|
||||
}
|
||||
frames.append(contentsOf: Self.extractPackets(from: output))
|
||||
// .haveData means the output buffer filled and more may be ready;
|
||||
// anything else means the converter wants more input.
|
||||
if status != .haveData { return frames }
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
|
||||
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
|
||||
var frames: [Data] = []
|
||||
frames.reserveCapacity(Int(buffer.packetCount))
|
||||
for index in 0..<Int(buffer.packetCount) {
|
||||
let description = descriptions[index]
|
||||
guard description.mDataByteSize > 0 else { continue }
|
||||
let start = buffer.data.advanced(by: Int(description.mStartOffset))
|
||||
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
|
||||
}
|
||||
return frames
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
|
||||
/// inbound burst. Not thread-safe — confine to one queue/actor.
|
||||
final class PTTFrameDecoder {
|
||||
private let converter: AVAudioConverter
|
||||
private let pcmFormat: AVAudioFormat
|
||||
private let aacFormat: AVAudioFormat
|
||||
|
||||
init?() {
|
||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||
let aac = PTTAudioFormat.aacFormat,
|
||||
let converter = AVAudioConverter(from: aac, to: pcm)
|
||||
else { return nil }
|
||||
self.converter = converter
|
||||
self.pcmFormat = pcm
|
||||
self.aacFormat = aac
|
||||
}
|
||||
|
||||
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
|
||||
/// while the decoder is still priming (the first frame of a stream).
|
||||
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
|
||||
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
|
||||
|
||||
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
|
||||
frame.withUnsafeBytes { raw in
|
||||
guard let base = raw.baseAddress else { return }
|
||||
input.data.copyMemory(from: base, byteCount: frame.count)
|
||||
}
|
||||
input.byteLength = UInt32(frame.count)
|
||||
input.packetCount = 1
|
||||
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
||||
mStartOffset: 0,
|
||||
mVariableFramesInPacket: 0,
|
||||
mDataByteSize: UInt32(frame.count)
|
||||
)
|
||||
|
||||
guard let output = AVAudioPCMBuffer(
|
||||
pcmFormat: pcmFormat,
|
||||
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
|
||||
) else { return nil }
|
||||
|
||||
var consumed = false
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||
if consumed {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed = true
|
||||
outStatus.pointee = .haveData
|
||||
return input
|
||||
}
|
||||
guard status != .error else {
|
||||
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||
return nil
|
||||
}
|
||||
return output.frameLength > 0 ? output : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-rate/channel converter from the microphone's native format to the
|
||||
/// 16 kHz mono processing format. Stateful; not thread-safe.
|
||||
final class PTTInputResampler {
|
||||
private let converter: AVAudioConverter
|
||||
private let outputFormat: AVAudioFormat
|
||||
private let ratio: Double
|
||||
|
||||
init?(inputFormat: AVAudioFormat) {
|
||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||
let converter = AVAudioConverter(from: inputFormat, to: pcm)
|
||||
else { return nil }
|
||||
self.converter = converter
|
||||
self.outputFormat = pcm
|
||||
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
|
||||
}
|
||||
|
||||
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
||||
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
|
||||
|
||||
var consumed = false
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||
if consumed {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status != .error else {
|
||||
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||
return nil
|
||||
}
|
||||
return output.frameLength > 0 ? output : nil
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//
|
||||
// PTTAudioFormat.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
|
||||
/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings
|
||||
/// so a burst's finalized `.m4a` and its live frames sound the same.
|
||||
enum PTTAudioFormat {
|
||||
static let sampleRate: Double = 16_000
|
||||
static let channelCount: AVAudioChannelCount = 1
|
||||
static let bitRate = 16_000
|
||||
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
|
||||
static let samplesPerFrame: AVAudioFrameCount = 1024
|
||||
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
|
||||
|
||||
/// Uncompressed processing format (deinterleaved float PCM).
|
||||
static var pcmFormat: AVAudioFormat? {
|
||||
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
|
||||
}
|
||||
|
||||
/// Compressed wire format.
|
||||
static var aacFormat: AVAudioFormat? {
|
||||
var description = AudioStreamBasicDescription(
|
||||
mSampleRate: sampleRate,
|
||||
mFormatID: kAudioFormatMPEG4AAC,
|
||||
mFormatFlags: 0,
|
||||
mBytesPerPacket: 0,
|
||||
mFramesPerPacket: samplesPerFrame,
|
||||
mBytesPerFrame: 0,
|
||||
mChannelsPerFrame: channelCount,
|
||||
mBitsPerChannel: 0,
|
||||
mReserved: 0
|
||||
)
|
||||
return AVAudioFormat(streamDescription: &description)
|
||||
}
|
||||
|
||||
/// Voice-note container settings for the finalized `.m4a`, mirroring
|
||||
/// `VoiceRecorder.startRecording()`.
|
||||
static var voiceNoteFileSettings: [String: Any] {
|
||||
[
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: sampleRate,
|
||||
AVNumberOfChannelsKey: Int(channelCount),
|
||||
AVEncoderBitRateKey: bitRate
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
|
||||
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
|
||||
/// stream is playable at any prefix — a partially received burst is still a
|
||||
/// replayable voice note.
|
||||
enum ADTSFramer {
|
||||
private static let headerSize = 7
|
||||
/// MPEG-4 sampling frequency index for 16 kHz.
|
||||
private static let samplingFrequencyIndex: UInt8 = 8
|
||||
private static let channelConfiguration: UInt8 = 1
|
||||
|
||||
/// Wraps one raw AAC-LC frame in an ADTS header.
|
||||
static func frame(_ aacFrame: Data) -> Data {
|
||||
let frameLength = aacFrame.count + headerSize
|
||||
var data = Data(capacity: frameLength)
|
||||
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
|
||||
data.append(0xFF)
|
||||
data.append(0xF1)
|
||||
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
|
||||
// private bit 0, channel config high bit.
|
||||
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
|
||||
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
|
||||
data.append(UInt8((frameLength >> 3) & 0xFF))
|
||||
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
|
||||
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
|
||||
data.append(0xFC)
|
||||
data.append(aacFrame)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
//
|
||||
// PTTBurstPlayer.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
@preconcurrency import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// The engine operations behind live-burst playback, abstracted so the
|
||||
/// player's lifecycle (jitter start, category-escalation restart, stop) is
|
||||
/// unit-testable without real audio hardware.
|
||||
@MainActor
|
||||
protocol PTTPlaybackEngine: AnyObject {
|
||||
/// The object `AVAudioEngineConfigurationChange` notifications are posted
|
||||
/// for (nil for mocks — no observer is registered).
|
||||
var configChangeObject: AnyObject? { get }
|
||||
func start() throws
|
||||
func play()
|
||||
func stop()
|
||||
func schedule(
|
||||
_ buffer: AVAudioPCMBuffer,
|
||||
completionType: PTTPlaybackCompletionType,
|
||||
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
|
||||
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
|
||||
/// may arrive before the render pipeline has made the audio audible.
|
||||
enum PTTPlaybackCompletionType: Equatable, Sendable {
|
||||
case dataConsumed
|
||||
case dataPlayedBack
|
||||
}
|
||||
|
||||
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
|
||||
case dataConsumed
|
||||
case dataPlayedBack
|
||||
/// AVAudioPlayerNode invokes the requested callback when the node is
|
||||
/// stopped too. That is not audible completion and must remain replayable.
|
||||
case playbackStopped
|
||||
}
|
||||
|
||||
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
|
||||
/// an engine instantiated against an earlier audio-session configuration keeps
|
||||
/// rendering to the stale route (same class of failure as the capture side's
|
||||
/// fresh-engine-per-press rule).
|
||||
@MainActor
|
||||
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
|
||||
init(format: AVAudioFormat) {
|
||||
engine.attach(node)
|
||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||
}
|
||||
|
||||
var configChangeObject: AnyObject? { engine }
|
||||
|
||||
func start() throws {
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
}
|
||||
|
||||
func play() {
|
||||
node.play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
node.stop()
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
func schedule(
|
||||
_ buffer: AVAudioPCMBuffer,
|
||||
completionType: PTTPlaybackCompletionType,
|
||||
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
|
||||
) {
|
||||
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
|
||||
case .dataConsumed: .dataConsumed
|
||||
case .dataPlayedBack: .dataPlayedBack
|
||||
}
|
||||
let scheduledEngine = engine
|
||||
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
|
||||
// The API invokes this callback when the player is stopped as
|
||||
// well. A configuration change can stop the engine before its
|
||||
// notification reaches MainActor, so do not misclassify that
|
||||
// flushed tail as audible playback.
|
||||
guard scheduledEngine?.isRunning == true else {
|
||||
completionHandler(.playbackStopped)
|
||||
return
|
||||
}
|
||||
switch callbackType {
|
||||
case .dataConsumed:
|
||||
completionHandler(.dataConsumed)
|
||||
case .dataRendered:
|
||||
completionHandler(.dataConsumed)
|
||||
case .dataPlayedBack:
|
||||
completionHandler(.dataPlayedBack)
|
||||
@unknown default:
|
||||
completionHandler(.playbackStopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion callbacks arrive off the main actor, while engine rebuilds are
|
||||
/// serialized on it. This small lock-backed latch lets a rebuild atomically
|
||||
/// claim only buffers whose completion has not already fired — even when the
|
||||
/// callback's hop back to the main actor is still queued.
|
||||
private final class PTTPlaybackCompletionState: @unchecked Sendable {
|
||||
private enum State {
|
||||
case scheduled
|
||||
case completed
|
||||
case retired
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var state: State = .scheduled
|
||||
|
||||
/// Returns true exactly once when playback completion wins the race with
|
||||
/// an engine rebuild or stop.
|
||||
func complete() -> Bool {
|
||||
lock.withLock {
|
||||
guard case .scheduled = state else { return false }
|
||||
state = .completed
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true exactly once when a rebuild or stop claims this
|
||||
/// still-pending schedule. Later callbacks from that engine are stale.
|
||||
func retireIfPending() -> Bool {
|
||||
lock.withLock {
|
||||
guard case .scheduled = state else { return false }
|
||||
state = .retired
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays one inbound live voice burst with a small jitter buffer.
|
||||
///
|
||||
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
||||
/// an underrun (missing/late packets) simply pauses output until the next
|
||||
/// buffer arrives, which self-heals timing without explicit silence
|
||||
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
||||
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
||||
///
|
||||
/// Talk-over is bidirectional: when push-to-talk capture starts while this
|
||||
/// burst plays, the session category escalates underneath the engine — the
|
||||
/// player rebuilds a fresh engine against the new configuration and keeps
|
||||
/// streaming instead of dying. Real interruptions (phone call, route device
|
||||
/// gone) still stop it; the burst keeps assembling to file either way.
|
||||
@MainActor
|
||||
final class PTTBurstPlayer {
|
||||
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
|
||||
/// handful of category/route changes is plenty — beyond it something is
|
||||
/// thrashing and stopping cleanly beats an engine-rebuild loop.
|
||||
private static let maxEngineRestarts = 8
|
||||
|
||||
private let makeEngine: @MainActor () -> PTTPlaybackEngine
|
||||
private var engine: PTTPlaybackEngine
|
||||
private let decoder: PTTFrameDecoder
|
||||
private let coordinator: AudioSessionCoordinator
|
||||
/// Injectable so tests don't fight over the app-wide exclusive-playback
|
||||
/// slot (a parallel test's `play()` would stop this player mid-test).
|
||||
private let exclusivity: VoiceNotePlaybackCoordinator
|
||||
|
||||
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
||||
private var queuedDuration: TimeInterval = 0
|
||||
private struct ScheduledBuffer {
|
||||
let id: UInt64
|
||||
let buffer: AVAudioPCMBuffer
|
||||
let completionState: PTTPlaybackCompletionState
|
||||
}
|
||||
/// Buffers handed to the current engine whose completion has not yet
|
||||
/// been processed on the main actor. Keeping the buffers themselves lets
|
||||
/// a category-escalation rebuild replay the unfinished tail in order.
|
||||
private var scheduledBuffers: [ScheduledBuffer] = []
|
||||
private var nextScheduledBufferID: UInt64 = 0
|
||||
/// Bumped on every engine rebuild or stop so completion tasks from a
|
||||
/// torn-down engine cannot mutate the current generation's pending list.
|
||||
private var engineGeneration = 0
|
||||
private var engineRestarts = 0
|
||||
private var engineStarted = false
|
||||
private var finished = false
|
||||
/// Latched off (internal read so tests can await the async failure path).
|
||||
private(set) var stopped = false
|
||||
/// A session acquire is in flight (it suspends off-main for the blocking
|
||||
/// session IPC); gates `startIfReady` against double acquisition.
|
||||
private var acquiringSession = false
|
||||
private var deadlineTask: Task<Void, Never>?
|
||||
private var sessionToken: AudioSessionCoordinator.Token?
|
||||
/// Reserved before the session acquire suspends. Activation succeeds only
|
||||
/// if no newer playback request claimed the floor in the meantime.
|
||||
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
|
||||
private var configChangeObserver: NSObjectProtocol?
|
||||
|
||||
private(set) var isPlaying = false
|
||||
|
||||
/// Fires exactly once when the player stops for good (drain-out, cancel,
|
||||
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
|
||||
/// the draining player it keeps alive after the assembly — the player's
|
||||
/// only long-lived owner — is discarded on burst END.
|
||||
var onStopped: (() -> Void)?
|
||||
|
||||
init?(
|
||||
coordinator: AudioSessionCoordinator? = nil,
|
||||
exclusivity: VoiceNotePlaybackCoordinator? = nil,
|
||||
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
|
||||
) {
|
||||
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||
self.decoder = decoder
|
||||
self.coordinator = coordinator ?? .shared
|
||||
self.exclusivity = exclusivity ?? .shared
|
||||
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
|
||||
self.makeEngine = factory
|
||||
self.engine = factory()
|
||||
|
||||
deadlineTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
||||
self?.startIfReady(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Backstop for an owner dropping the player before it stopped: the
|
||||
// session coordinator retains registered tokens strongly, so a token
|
||||
// leaked here would keep the session active (and pin any escalated
|
||||
// category) for the app's lifetime. `release` is fire-and-forget
|
||||
// onto the coordinator's queue, so it is deinit-safe.
|
||||
if let token = sessionToken {
|
||||
coordinator.release(token)
|
||||
}
|
||||
if let observer = configChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
deadlineTask?.cancel()
|
||||
}
|
||||
|
||||
/// Decodes and queues frames (in burst order). Starts playback when the
|
||||
/// jitter buffer fills.
|
||||
func enqueue(_ frames: [Data]) {
|
||||
guard !stopped else { return }
|
||||
for frame in frames {
|
||||
guard let pcm = decoder.decode(frame) else { continue }
|
||||
if engineStarted {
|
||||
schedule(pcm)
|
||||
} else {
|
||||
queuedBuffers.append(pcm)
|
||||
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
|
||||
}
|
||||
}
|
||||
startIfReady(force: false)
|
||||
}
|
||||
|
||||
/// The burst ended: stop once everything scheduled has played out.
|
||||
func finishAfterDrain() {
|
||||
finished = true
|
||||
// The complete burst is queued — no jitter left to wait for. This
|
||||
// also matters when END lands while the async session acquire is
|
||||
// still in flight: the queued audio must play out, not be treated
|
||||
// as already drained.
|
||||
startIfReady(force: true)
|
||||
stopIfDrained()
|
||||
}
|
||||
|
||||
/// Immediate stop (cancel, another playback taking over, interruption,
|
||||
/// teardown).
|
||||
func stop() {
|
||||
guard !stopped else { return }
|
||||
stopped = true
|
||||
deadlineTask?.cancel()
|
||||
removeConfigObserver()
|
||||
queuedBuffers = []
|
||||
retireScheduledBuffers()
|
||||
if engineStarted {
|
||||
engine.stop()
|
||||
}
|
||||
isPlaying = false
|
||||
releaseSessionToken()
|
||||
exclusivity.deactivate(self)
|
||||
onStopped?()
|
||||
}
|
||||
|
||||
private func startIfReady(force: Bool) {
|
||||
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
|
||||
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||
|
||||
// Acquiring the session suspends for its blocking IPC (off the main
|
||||
// actor); frames arriving meanwhile keep queueing and are flushed
|
||||
// onto the engine once it starts.
|
||||
acquiringSession = true
|
||||
playbackReservation = exclusivity.reserve(self)
|
||||
Task { [weak self] in
|
||||
await self?.acquireSessionAndStart()
|
||||
}
|
||||
}
|
||||
|
||||
private func acquireSessionAndStart() async {
|
||||
let token: AudioSessionCoordinator.Token
|
||||
do {
|
||||
token = try await coordinator.acquire(
|
||||
.playback,
|
||||
onInterrupted: { [weak self] in self?.stop() },
|
||||
onCategoryEscalated: { [weak self] in self?.restartEngine() }
|
||||
)
|
||||
} catch {
|
||||
acquiringSession = false
|
||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||
// Playing unregistered would leave the engine exposed: another
|
||||
// holder's last release deactivates the session mid-play, and no
|
||||
// interruption/escalation fan-out ever reaches us. Bail like the
|
||||
// engine-start failure below; the burst still assembles to file.
|
||||
// (stop() also fires onStopped so a parked draining player is
|
||||
// unparked instead of leaking.)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
acquiringSession = false
|
||||
// stop() (cancel, exclusivity, teardown) may have landed while the
|
||||
// session was activating: hand the token straight back.
|
||||
guard !stopped else {
|
||||
coordinator.release(token)
|
||||
return
|
||||
}
|
||||
sessionToken = token
|
||||
guard let playbackReservation,
|
||||
exclusivity.isCurrent(playbackReservation, for: self)
|
||||
else {
|
||||
// The request was superseded while audio-session activation was
|
||||
// suspended. Do not even start the retired engine.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// Observe reconfiguration before starting so nothing lands between.
|
||||
registerConfigObserver()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
// A capture racing this start can reconfigure the session while
|
||||
// the engine spins up (its escalation fan-out no-ops on a player
|
||||
// that never started): rebuild once against the settled
|
||||
// configuration — counted against the restart cap — before
|
||||
// giving up.
|
||||
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
|
||||
removeConfigObserver()
|
||||
engineRestarts += 1
|
||||
engine = makeEngine()
|
||||
registerConfigObserver()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||
// stop() removes the observer, hands the token back, and
|
||||
// fires onStopped for any parked draining owner.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
engineStarted = true
|
||||
guard exclusivity.activate(self, reservation: playbackReservation)
|
||||
else {
|
||||
// A newer user-initiated playback reserved the floor while this
|
||||
// older PTT request was suspended in audio-session activation.
|
||||
// Never let the late completion steal playback back.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
isPlaying = true
|
||||
engine.play()
|
||||
|
||||
let buffered = queuedBuffers
|
||||
queuedBuffers = []
|
||||
queuedDuration = 0
|
||||
for buffer in buffered {
|
||||
schedule(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio session was reconfigured underneath the running engine
|
||||
/// (category escalation for talk-over, or an engine configuration
|
||||
/// change): rebuild a fresh engine against the new configuration and
|
||||
/// keep streaming. Buffers already completed stay completed; the
|
||||
/// unfinished scheduled tail is replayed in order on the fresh engine,
|
||||
/// and frames still arriving continue scheduling after it.
|
||||
private func restartEngine() {
|
||||
guard engineStarted, !stopped else { return }
|
||||
engineRestarts += 1
|
||||
guard engineRestarts <= Self.maxEngineRestarts else {
|
||||
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
removeConfigObserver()
|
||||
// Claim the unfinished tail before stopping the old engine. Stopping
|
||||
// a player node may itself invoke its completion handlers; retiring
|
||||
// the claimed entries first makes those callbacks unambiguously stale.
|
||||
// A completion that fired just before this rebuild wins the latch and
|
||||
// is excluded even if its MainActor task has not run yet.
|
||||
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
|
||||
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
|
||||
}
|
||||
scheduledBuffers = []
|
||||
engineGeneration += 1
|
||||
engine.stop()
|
||||
engine = makeEngine()
|
||||
registerConfigObserver()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
engine.play()
|
||||
for buffer in buffersToReplay {
|
||||
schedule(buffer)
|
||||
}
|
||||
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
|
||||
// If every old buffer completed before the rebuild, a finished burst
|
||||
// can stop now. Otherwise the replayed tail keeps it alive until its
|
||||
// new-generation completions arrive.
|
||||
stopIfDrained()
|
||||
}
|
||||
|
||||
private func registerConfigObserver() {
|
||||
guard let object = engine.configChangeObject else { return }
|
||||
configChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: object,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.restartEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeConfigObserver() {
|
||||
if let observer = configChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
configChangeObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||
let id = nextScheduledBufferID
|
||||
nextScheduledBufferID &+= 1
|
||||
let completionState = PTTPlaybackCompletionState()
|
||||
scheduledBuffers.append(ScheduledBuffer(
|
||||
id: id,
|
||||
buffer: buffer,
|
||||
completionState: completionState
|
||||
))
|
||||
let generation = engineGeneration
|
||||
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
|
||||
guard event == .dataPlayedBack else { return }
|
||||
// Mark completion before hopping to MainActor. A rebuild can then
|
||||
// distinguish already-completed audio from an unfinished tail
|
||||
// even when this task has not run yet.
|
||||
guard completionState.complete() else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.engineGeneration == generation else { return }
|
||||
self.scheduledBuffers.removeAll { $0.id == id }
|
||||
self.stopIfDrained()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retireScheduledBuffers() {
|
||||
engineGeneration += 1
|
||||
for scheduled in scheduledBuffers {
|
||||
_ = scheduled.completionState.retireIfPending()
|
||||
}
|
||||
scheduledBuffers = []
|
||||
}
|
||||
|
||||
private func stopIfDrained() {
|
||||
guard finished, scheduledBuffers.isEmpty else { return }
|
||||
// Started: everything scheduled has played out. Never started with
|
||||
// nothing queued or in flight (e.g. no decodable frames): nothing
|
||||
// will ever play. Otherwise the engine start is still pending (the
|
||||
// async session acquire) and the queued audio must play out first.
|
||||
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
|
||||
stop()
|
||||
}
|
||||
|
||||
private func releaseSessionToken() {
|
||||
sessionToken.map(coordinator.release)
|
||||
sessionToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension PTTBurstPlayer: ExclusivePlayback {
|
||||
/// A live stream can't meaningfully pause; yielding the floor stops it.
|
||||
/// The burst keeps assembling to file, so nothing is lost.
|
||||
nonisolated func pauseForExclusivity() {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
//
|
||||
// PTTCaptureEngine.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Owns one capture token and returns it even when the capture engine's owner
|
||||
/// disappears without reaching its normal stop/cancel path. The coordinator
|
||||
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
|
||||
/// reclaim an abandoned hold.
|
||||
final class PTTCaptureSessionLease: @unchecked Sendable {
|
||||
private let coordinator: AudioSessionCoordinator
|
||||
private let lock = NSLock()
|
||||
private var token: AudioSessionCoordinator.Token?
|
||||
|
||||
init(coordinator: AudioSessionCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
func install(_ token: AudioSessionCoordinator.Token) {
|
||||
let previous = lock.withLock {
|
||||
let previous = self.token
|
||||
self.token = token
|
||||
return previous
|
||||
}
|
||||
previous.map(coordinator.release)
|
||||
}
|
||||
|
||||
func release() {
|
||||
let token = lock.withLock {
|
||||
let token = self.token
|
||||
self.token = nil
|
||||
return token
|
||||
}
|
||||
token.map(coordinator.release)
|
||||
}
|
||||
|
||||
deinit {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic capture identity shared by main-actor lifecycle code and queued
|
||||
/// engine callbacks. Removing a notification observer does not cancel a block
|
||||
/// already enqueued on the main queue, so every callback must also prove it
|
||||
/// still belongs to the current hold before mutating capture state.
|
||||
final class PTTCaptureGeneration: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: UInt = 0
|
||||
|
||||
func begin() -> UInt {
|
||||
lock.withLock {
|
||||
value &+= 1
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
lock.withLock { value &+= 1 }
|
||||
}
|
||||
|
||||
func invalidate(ifCurrent generation: UInt) -> Bool {
|
||||
lock.withLock {
|
||||
guard value == generation else { return false }
|
||||
value &+= 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func isCurrent(_ generation: UInt) -> Bool {
|
||||
lock.withLock { value == generation }
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
||||
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
||||
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
||||
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
||||
/// handles delivery to receivers that missed the live stream.
|
||||
/// `@unchecked Sendable`: every mutable property is confined to one executor —
|
||||
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
|
||||
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) — so
|
||||
/// weak references may cross the `@Sendable` tap/notification closures, which
|
||||
/// immediately hop back to the owning executor.
|
||||
final class PTTCaptureEngine: @unchecked Sendable {
|
||||
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
||||
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
||||
private static let maxCaptureDuration: TimeInterval = 120
|
||||
|
||||
/// Recreated on every `start()`: an engine whose input unit was
|
||||
/// instantiated against an earlier (playback-only or inactive) audio
|
||||
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
|
||||
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
||||
private var engine = AVAudioEngine()
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
||||
private let coordinator: AudioSessionCoordinator
|
||||
private let sessionLease: PTTCaptureSessionLease
|
||||
private let captureGeneration = PTTCaptureGeneration()
|
||||
|
||||
// Capture-queue-confined state.
|
||||
private var resampler: PTTInputResampler?
|
||||
private var encoder: PTTFrameEncoder?
|
||||
private var file: AVAudioFile?
|
||||
private var fileURL: URL?
|
||||
private var encodedFrameCount = 0
|
||||
private var running = false
|
||||
private var captureStart = Date()
|
||||
/// Whether `engine.start()` succeeded for the current capture
|
||||
/// (see `stopEngineIfStarted`).
|
||||
@MainActor private var engineStarted = false
|
||||
@MainActor private var configChangeObserver: NSObjectProtocol?
|
||||
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||
var onFrames: (([Data]) -> Void)?
|
||||
|
||||
enum CaptureError: Error {
|
||||
case inputUnavailable
|
||||
case audioSetupFailed
|
||||
}
|
||||
|
||||
init(coordinator: AudioSessionCoordinator = .shared) {
|
||||
self.coordinator = coordinator
|
||||
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
|
||||
}
|
||||
|
||||
deinit {
|
||||
sessionLease.release()
|
||||
}
|
||||
|
||||
/// Async because acquiring the session hops its blocking IPC off the main
|
||||
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
|
||||
/// itself still starts back on main once the session is configured.
|
||||
@MainActor
|
||||
func start(outputURL: URL) async throws {
|
||||
let generation = captureGeneration.begin()
|
||||
let token = try await coordinator.acquire(.capture) { [weak self] in
|
||||
self?.handleInterruption(for: generation)
|
||||
}
|
||||
// The hold ended (stop/cancel) while the session was activating:
|
||||
// starting the engine now would leave a hot mic after release.
|
||||
guard captureGeneration.isCurrent(generation) else {
|
||||
coordinator.release(token)
|
||||
throw CancellationError()
|
||||
}
|
||||
sessionLease.install(token)
|
||||
do {
|
||||
try beginCapture(outputURL: outputURL, generation: generation)
|
||||
} catch {
|
||||
releaseSessionToken()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func beginCapture(outputURL: URL, generation: UInt) throws {
|
||||
// Fresh engine per capture so its input unit binds to the session
|
||||
// that is active *now* (see `engine` doc comment).
|
||||
engine = AVAudioEngine()
|
||||
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
||||
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||
throw CaptureError.inputUnavailable
|
||||
}
|
||||
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
|
||||
let encoder = PTTFrameEncoder(),
|
||||
let pcmFormat = PTTAudioFormat.pcmFormat
|
||||
else { throw CaptureError.audioSetupFailed }
|
||||
|
||||
let file = try AVAudioFile(
|
||||
forWriting: outputURL,
|
||||
settings: PTTAudioFormat.voiceNoteFileSettings,
|
||||
commonFormat: pcmFormat.commonFormat,
|
||||
interleaved: pcmFormat.isInterleaved
|
||||
)
|
||||
|
||||
queue.sync {
|
||||
self.resampler = resampler
|
||||
self.encoder = encoder
|
||||
self.file = file
|
||||
self.fileURL = outputURL
|
||||
self.encodedFrameCount = 0
|
||||
self.captureStart = Date()
|
||||
self.running = true
|
||||
}
|
||||
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||
self?.queue.async { self?.process(buffer, generation: generation) }
|
||||
}
|
||||
// Route/category changes reconfigure the engine underneath the tap;
|
||||
// stop and finalize cleanly — the .m4a captured so far still sends.
|
||||
// Registered before start() so no reconfigure lands unobserved
|
||||
// (handleInterruption also validates this capture generation).
|
||||
configChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: engine,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleInterruption(for: generation)
|
||||
}
|
||||
}
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
||||
if let observer = configChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
configChangeObserver = nil
|
||||
}
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
queue.sync { self.teardown(deleteFile: true) }
|
||||
throw error
|
||||
}
|
||||
engineStarted = true
|
||||
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
||||
}
|
||||
|
||||
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
||||
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
||||
@MainActor
|
||||
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||
captureGeneration.invalidate()
|
||||
stopEngineIfStarted()
|
||||
let result: (URL?, Int) = queue.sync {
|
||||
let url = fileURL
|
||||
let frames = encodedFrameCount
|
||||
teardown(deleteFile: false)
|
||||
return (url, frames)
|
||||
}
|
||||
releaseSessionToken()
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel() {
|
||||
captureGeneration.invalidate()
|
||||
stopEngineIfStarted()
|
||||
queue.sync { teardown(deleteFile: true) }
|
||||
releaseSessionToken()
|
||||
}
|
||||
|
||||
/// Audio session interrupted (call, Siri) or the engine was reconfigured
|
||||
/// mid-capture: behave like `stop()` — finalize the `.m4a` container but
|
||||
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
|
||||
/// still returns the note for delivery.
|
||||
@MainActor
|
||||
private func handleInterruption(for generation: UInt) {
|
||||
// Also invalidate a start whose acquire has registered its token but
|
||||
// has not returned to this actor yet. Without this bump the callback
|
||||
// is lost while `engineStarted == false`, and the resumed start can
|
||||
// open the mic after the stop signal.
|
||||
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
|
||||
guard engineStarted else {
|
||||
releaseSessionToken()
|
||||
return
|
||||
}
|
||||
stopEngineIfStarted()
|
||||
queue.sync {
|
||||
running = false
|
||||
// Releasing the AVAudioFile finalizes the .m4a container.
|
||||
file = nil
|
||||
encoder = nil
|
||||
resampler = nil
|
||||
}
|
||||
releaseSessionToken()
|
||||
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
|
||||
}
|
||||
|
||||
/// Touching `inputNode` on an engine that never started instantiates its
|
||||
/// input unit against whatever session is active and spams AURemoteIO
|
||||
/// errors — a canceled-before-start hold must not touch the engine.
|
||||
@MainActor
|
||||
private func stopEngineIfStarted() {
|
||||
if let observer = configChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
configChangeObserver = nil
|
||||
}
|
||||
guard engineStarted else { return }
|
||||
engineStarted = false
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func releaseSessionToken() {
|
||||
sessionLease.release()
|
||||
}
|
||||
|
||||
// MARK: - Capture queue
|
||||
|
||||
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
|
||||
guard captureGeneration.isCurrent(generation),
|
||||
running,
|
||||
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||
let resampled = resampler?.resample(buffer)
|
||||
else { return }
|
||||
|
||||
do {
|
||||
try file?.write(from: resampled)
|
||||
} catch {
|
||||
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
|
||||
}
|
||||
|
||||
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
|
||||
encodedFrameCount += frames.count
|
||||
onFrames?(frames)
|
||||
}
|
||||
|
||||
private func teardown(deleteFile: Bool) {
|
||||
running = false
|
||||
// Releasing the AVAudioFile finalizes the .m4a container.
|
||||
file = nil
|
||||
encoder = nil
|
||||
resampler = nil
|
||||
if deleteFile, let url = fileURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
fileURL = nil
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// PTTSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// User preference for live push-to-talk voice. One switch controls both
|
||||
/// directions: streaming your holds live, and auto-playing inbound bursts.
|
||||
/// Off means voice messages behave exactly like classic voice notes.
|
||||
enum PTTSettings {
|
||||
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
|
||||
|
||||
static var liveVoiceEnabled: Bool {
|
||||
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
|
||||
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
|
||||
}
|
||||
|
||||
/// Autoplay is foreground-only: audio must never start from the
|
||||
/// background.
|
||||
@MainActor
|
||||
static var isAppActive: Bool {
|
||||
#if os(iOS)
|
||||
return UIApplication.shared.applicationState == .active
|
||||
#elseif os(macOS)
|
||||
return NSApplication.shared.isActive
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
//
|
||||
// VoiceCaptureSession.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Capture backend behind the composer's hold-to-record gesture.
|
||||
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
|
||||
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
|
||||
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
|
||||
/// additionally streams frames live while the button is held.
|
||||
@MainActor
|
||||
protocol VoiceCaptureSession: AnyObject {
|
||||
/// Whether audio is leaving the device in real time while recording —
|
||||
/// drives the composer's LIVE treatment.
|
||||
var isLive: Bool { get }
|
||||
func requestPermission() async -> Bool
|
||||
func start() async throws
|
||||
/// Stops capture and returns the finalized voice-note file, or nil when
|
||||
/// nothing valid was captured.
|
||||
func finish() async -> URL?
|
||||
func cancel() async
|
||||
/// Stops capture and suppresses every later send before returning.
|
||||
func panicCancelSynchronously()
|
||||
}
|
||||
|
||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||
@MainActor
|
||||
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||
private let recorder: VoiceRecorder
|
||||
private let owner = VoiceRecorder.RecordingOwner()
|
||||
|
||||
var isLive: Bool { false }
|
||||
|
||||
init(recorder: VoiceRecorder = .shared) {
|
||||
self.recorder = recorder
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
await recorder.requestPermission()
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await recorder.startRecording(owner: owner)
|
||||
}
|
||||
|
||||
func finish() async -> URL? {
|
||||
await recorder.stopRecording(owner: owner)
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
await recorder.cancelRecording(owner: owner)
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
recorder.panicCancelSynchronously(owner: owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Testable surface of the live capture engine. Production uses
|
||||
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
|
||||
/// real audio hardware.
|
||||
@MainActor
|
||||
protocol PTTCapturing: AnyObject {
|
||||
var onFrames: (([Data]) -> Void)? { get set }
|
||||
func start(outputURL: URL) async throws
|
||||
func stop() -> (url: URL?, encodedFrames: Int)
|
||||
func cancel()
|
||||
}
|
||||
|
||||
extension PTTCaptureEngine: PTTCapturing {}
|
||||
|
||||
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
||||
/// recording, then finalizes the same audio as a standard voice note whose
|
||||
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
||||
/// heard the live stream absorb the note silently instead of seeing a
|
||||
/// duplicate.
|
||||
@MainActor
|
||||
final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
let burstID: Data
|
||||
|
||||
private let sendPacket: (Data) -> Void
|
||||
private let capture: any PTTCapturing
|
||||
private let now: () -> Date
|
||||
/// Capture-queue-confined stream state: packetizes frames and lazily
|
||||
/// emits START so packet order is guaranteed by queue serialization.
|
||||
private final class StreamState {
|
||||
var packetizer: VoiceBurstPacketizer
|
||||
var sentStart = false
|
||||
init(burstID: Data) {
|
||||
packetizer = VoiceBurstPacketizer(burstID: burstID)
|
||||
}
|
||||
}
|
||||
private let stream: StreamState
|
||||
private var startDate: Date?
|
||||
private var completed = false
|
||||
|
||||
var isLive: Bool { true }
|
||||
|
||||
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
||||
/// target peer; must be safe to call from any queue (BLEService hops to
|
||||
/// its own message queue internally).
|
||||
init(
|
||||
sendPacket: @escaping (Data) -> Void,
|
||||
capture: (any PTTCapturing)? = nil,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
burstID: Data? = nil
|
||||
) {
|
||||
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
|
||||
self.sendPacket = sendPacket
|
||||
self.capture = capture ?? PTTCaptureEngine()
|
||||
self.now = now
|
||||
self.stream = StreamState(burstID: self.burstID)
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
await VoiceRecorder.shared.requestPermission()
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
let outputURL = try Self.makeOutputURL(burstID: burstID)
|
||||
let sendPacket = sendPacket
|
||||
let stream = stream
|
||||
capture.onFrames = { frames in
|
||||
if !stream.sentStart {
|
||||
stream.sentStart = true
|
||||
if let start = VoiceBurstPacket(
|
||||
burstID: stream.packetizer.burstID,
|
||||
seq: 0,
|
||||
kind: .start(codec: .aacLC16kMono)
|
||||
) {
|
||||
sendPacket(start.encode())
|
||||
}
|
||||
}
|
||||
for frame in frames {
|
||||
for packet in stream.packetizer.add(frame) {
|
||||
sendPacket(packet)
|
||||
}
|
||||
}
|
||||
// Flush per callback batch: at ~130-byte frames the budget fits
|
||||
// one frame per packet anyway, and holding residue would add
|
||||
// ~100 ms of avoidable latency.
|
||||
for packet in stream.packetizer.flush() {
|
||||
sendPacket(packet)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await capture.start(outputURL: outputURL)
|
||||
} catch is CancellationError {
|
||||
// The hold was released/canceled while the session acquire was
|
||||
// in flight: the engine never started and the capture already
|
||||
// handed its token back — nothing to retry. A coordinator-side
|
||||
// interruption during handoff also cancels acquire, but that is
|
||||
// not a successful start and must propagate to the view model.
|
||||
guard completed else { throw CancellationError() }
|
||||
return
|
||||
} catch {
|
||||
// The HAL can briefly report a dead input right after the audio
|
||||
// session (re)activates while the route settles; one retry after
|
||||
// a short pause covers it (observed on iPhone field tests).
|
||||
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
// The hold may have been released/canceled during the retry pause.
|
||||
// Starting the mic now would leave it live and streaming after the
|
||||
// user let go, so bail instead of opening a hot mic.
|
||||
guard !completed else {
|
||||
capture.cancel()
|
||||
return
|
||||
}
|
||||
try await capture.start(outputURL: outputURL)
|
||||
}
|
||||
startDate = now()
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||
}
|
||||
|
||||
func finish() async -> URL? {
|
||||
guard !completed else { return nil }
|
||||
completed = true
|
||||
|
||||
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
|
||||
let (url, encodedFrames) = capture.stop()
|
||||
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||
|
||||
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
|
||||
|
||||
guard elapsed >= VoiceRecorder.minRecordingDuration,
|
||||
capturedDuration >= VoiceRecorder.minRecordingDuration,
|
||||
let url
|
||||
else {
|
||||
sendControlPacket(.canceled)
|
||||
if let url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for packet in stream.packetizer.flush() {
|
||||
sendPacket(packet)
|
||||
}
|
||||
let durationMs = UInt32((capturedDuration * 1000).rounded())
|
||||
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
||||
return url
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
let alreadyCompleted = completed
|
||||
completed = true
|
||||
// Always tear down the capture, even if a quick-release already marked
|
||||
// us completed: the engine can start late (during start()'s retry
|
||||
// pause), and only capture.cancel() stops the mic and deactivates the
|
||||
// session. It is idempotent, so a redundant call is harmless.
|
||||
capture.cancel()
|
||||
if !alreadyCompleted {
|
||||
sendControlPacket(.canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
// Do not emit a canceled packet: it would itself be pre-panic
|
||||
// conversation data racing the emergency transport reset.
|
||||
completed = true
|
||||
capture.cancel()
|
||||
}
|
||||
|
||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||
sendPacket(packet.encode())
|
||||
}
|
||||
|
||||
private static func makeOutputURL(burstID: Data) throws -> URL {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// Internal lifecycle visibility for deterministic acquisition tests.
|
||||
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
@@ -27,24 +24,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
/// Test seam; `AudioSessionCoordinator.shared` when nil.
|
||||
private let sessionCoordinatorOverride: AudioSessionCoordinator?
|
||||
/// Injectable so tests don't fight over the app-wide exclusive-playback
|
||||
/// slot (a parallel test's `play()` would pause this controller mid-test).
|
||||
private let exclusivity: VoiceNotePlaybackCoordinator
|
||||
private var sessionToken: AudioSessionCoordinator.Token?
|
||||
/// A session acquire is in flight (it suspends off-main for the blocking
|
||||
/// session IPC); gates against double acquisition on rapid play taps.
|
||||
private var sessionAcquireInFlight = false
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
sessionCoordinator: AudioSessionCoordinator? = nil,
|
||||
exclusivity: VoiceNotePlaybackCoordinator? = nil
|
||||
) {
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
self.sessionCoordinatorOverride = sessionCoordinator
|
||||
self.exclusivity = exclusivity ?? .shared
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
@@ -69,16 +51,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
player?.stop()
|
||||
// A per-row @StateObject can be discarded mid-playback (navigating
|
||||
// away). Leaking the token here would hold the session forever —
|
||||
// never deactivating it, and pinning any escalated category for the
|
||||
// app's lifetime. `release` is fire-and-forget onto the coordinator's
|
||||
// queue, so it is deinit-safe: only the Sendable token crosses.
|
||||
if let token = sessionToken {
|
||||
sessionToken = nil
|
||||
(sessionCoordinatorOverride ?? .shared).release(token)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
@@ -96,15 +68,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
exclusivity.activate(self)
|
||||
isPlaying = true
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
player?.play()
|
||||
startTimer()
|
||||
updateProgress()
|
||||
// Acquired here (not in ensurePlayerReady): scrubbing a paused note
|
||||
// must not hold the session while nothing is audible. The session
|
||||
// calls block on audio-server IPC, so they run off the main thread;
|
||||
// the player starts once the session is configured.
|
||||
startPlayerAfterAcquiringSession()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
@@ -112,7 +80,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -121,8 +88,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
exclusivity.deactivate(self)
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
@@ -130,11 +96,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
// While the session acquire is still in flight, don't start
|
||||
// audio pre-activation — the pending acquire's completion starts
|
||||
// playback (from the new position) once the session resolves.
|
||||
if isPlaying, !sessionAcquireInFlight {
|
||||
startPreparedPlayer()
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
@@ -149,20 +112,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
self.releaseSession()
|
||||
self.exclusivity.deactivate(self)
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Load metadata synchronously, but do not call prepareToPlay here:
|
||||
// paused scrubbing reaches this path and must not acquire playback
|
||||
// hardware outside the AudioSessionCoordinator token lifetime.
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
@@ -180,81 +141,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
return player != nil
|
||||
}
|
||||
|
||||
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
|
||||
/// delegate's main-queue hop) run on the main thread; the acquire itself
|
||||
/// suspends while the blocking session IPC runs on the coordinator's
|
||||
/// queue, and the player starts when it resolves. An acquire failure
|
||||
/// leaves playback stopped: starting without a registered token would
|
||||
/// bypass interruption fan-out and the coordinator's refcount. A
|
||||
/// pause/stop landing mid-acquire hands the token straight back.
|
||||
private func startPlayerAfterAcquiringSession() {
|
||||
if sessionToken != nil {
|
||||
startPreparedPlayer()
|
||||
return
|
||||
}
|
||||
guard !sessionAcquireInFlight else { return }
|
||||
sessionAcquireInFlight = true
|
||||
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
|
||||
Task { @MainActor [weak self] in
|
||||
var token: AudioSessionCoordinator.Token?
|
||||
do {
|
||||
token = try await coordinator.acquire(.playback) { [weak self] in
|
||||
self?.pause()
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
guard let self else {
|
||||
// The row was discarded while acquiring; deinit had no token
|
||||
// to release yet.
|
||||
token.map(coordinator.release)
|
||||
return
|
||||
}
|
||||
self.sessionAcquireInFlight = false
|
||||
guard self.isPlaying else {
|
||||
// Paused/stopped while the session was activating.
|
||||
token.map(coordinator.release)
|
||||
return
|
||||
}
|
||||
guard let token else {
|
||||
self.failPlaybackStart()
|
||||
return
|
||||
}
|
||||
self.sessionToken = token
|
||||
self.startPreparedPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func startPreparedPlayer() -> Bool {
|
||||
guard let player,
|
||||
player.prepareToPlay(),
|
||||
player.play()
|
||||
else {
|
||||
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
|
||||
failPlaybackStart()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func failPlaybackStart() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
exclusivity.deactivate(self)
|
||||
}
|
||||
|
||||
private func releaseSession() {
|
||||
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
|
||||
sessionToken = nil
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
@@ -283,75 +181,25 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
}
|
||||
}
|
||||
|
||||
/// Something that can hold the app's single audio-playback slot and yield it
|
||||
/// when another playback starts (voice notes pause; live bursts stop).
|
||||
protocol ExclusivePlayback: AnyObject {
|
||||
func pauseForExclusivity()
|
||||
}
|
||||
|
||||
extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||
func pauseForExclusivity() {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one voice playback (note or live burst) runs at a time.
|
||||
/// Ensures only one voice note plays at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
struct Reservation: Equatable {
|
||||
fileprivate let generation: UInt64
|
||||
}
|
||||
private weak var activeController: VoiceNotePlaybackController?
|
||||
|
||||
private weak var activeController: (any ExclusivePlayback)?
|
||||
private weak var latestReservedController: (any ExclusivePlayback)?
|
||||
private var latestReservation = Reservation(generation: 0)
|
||||
private init() {}
|
||||
|
||||
/// Internal so tests can isolate their own exclusivity slot; the app
|
||||
/// uses `shared`.
|
||||
init() {}
|
||||
|
||||
/// Records playback intent without interrupting audio that is already
|
||||
/// audible. Async starters reserve before suspension, then activate only
|
||||
/// after their audio resource is ready.
|
||||
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
|
||||
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
|
||||
latestReservedController = controller
|
||||
return latestReservation
|
||||
}
|
||||
|
||||
/// Immediate activation for synchronous/user-initiated playback.
|
||||
@discardableResult
|
||||
func activate(_ controller: any ExclusivePlayback) -> Reservation {
|
||||
let reservation = reserve(controller)
|
||||
_ = activate(controller, reservation: reservation)
|
||||
return reservation
|
||||
}
|
||||
|
||||
/// Commits an earlier reservation only when it is still the newest
|
||||
/// playback request. This prevents an older async acquire from stealing
|
||||
/// the floor after a newer play gesture.
|
||||
@discardableResult
|
||||
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
|
||||
guard isCurrent(reservation, for: controller) else { return false }
|
||||
func activate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
return true
|
||||
return
|
||||
}
|
||||
activeController?.pauseForExclusivity()
|
||||
activeController?.pause()
|
||||
activeController = controller
|
||||
return true
|
||||
}
|
||||
|
||||
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
|
||||
latestReservation == reservation && latestReservedController === controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: any ExclusivePlayback) {
|
||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
if latestReservedController === controller {
|
||||
latestReservedController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,22 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
|
||||
/// it behind a protocol lets lifecycle races be tested without opening the
|
||||
/// microphone on the test host.
|
||||
protocol VoiceAudioRecording: AnyObject {
|
||||
var isRecording: Bool { get }
|
||||
var isMeteringEnabled: Bool { get set }
|
||||
func prepareToRecord() -> Bool
|
||||
func record(forDuration duration: TimeInterval) -> Bool
|
||||
func stop()
|
||||
}
|
||||
|
||||
extension AVAudioRecorder: VoiceAudioRecording {}
|
||||
|
||||
protocol VoiceAudioRecorderCreating {
|
||||
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
|
||||
}
|
||||
|
||||
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
||||
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
return try AVAudioRecorder(url: url, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error, Equatable {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
case recordingInProgress
|
||||
case failedToStartRecording
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
/// Identity of one press/hold. Every lifecycle mutation must present the
|
||||
/// same owner that started the recorder, so a stale finish or cancel from
|
||||
/// another hold cannot stop or delete the current recording.
|
||||
final class RecordingOwner: @unchecked Sendable {}
|
||||
|
||||
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
|
||||
/// on wall-clock sleeps. Production uses the real padding delay.
|
||||
struct TestingHooks: Sendable {
|
||||
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
|
||||
|
||||
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
|
||||
self.waitForStopPadding = waitForStopPadding
|
||||
}
|
||||
}
|
||||
|
||||
private let sessionCoordinator: AudioSessionCoordinator
|
||||
private let recorderFactory: any VoiceAudioRecorderCreating
|
||||
private let permissionGranted: () -> Bool
|
||||
private let paddingInterval: TimeInterval
|
||||
private let maxRecordingDuration: TimeInterval
|
||||
private let outputDirectory: URL?
|
||||
private let testingHooks: TestingHooks
|
||||
|
||||
private var recorder: (any VoiceAudioRecording)?
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var sessionToken: AudioSessionCoordinator.Token?
|
||||
private var activeOwner: RecordingOwner?
|
||||
/// True only while `startRecording()` is suspended in session acquire.
|
||||
/// A second start is rejected instead of superseding the first one.
|
||||
private var startInFlight = false
|
||||
|
||||
init(
|
||||
sessionCoordinator: AudioSessionCoordinator = .shared,
|
||||
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
|
||||
permissionGranted: (() -> Bool)? = nil,
|
||||
paddingInterval: TimeInterval = 0.5,
|
||||
maxRecordingDuration: TimeInterval = 120,
|
||||
outputDirectory: URL? = nil,
|
||||
testingHooks: TestingHooks = TestingHooks()
|
||||
) {
|
||||
self.sessionCoordinator = sessionCoordinator
|
||||
self.recorderFactory = recorderFactory
|
||||
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
|
||||
self.paddingInterval = paddingInterval
|
||||
self.maxRecordingDuration = maxRecordingDuration
|
||||
self.outputDirectory = outputDirectory
|
||||
self.testingHooks = testingHooks
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@@ -115,130 +42,82 @@ actor VoiceRecorder {
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording(owner: RecordingOwner) async throws -> URL {
|
||||
if activeOwner != nil {
|
||||
func startRecording() throws -> URL {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
guard permissionGranted() else {
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
|
||||
activeOwner = owner
|
||||
startInFlight = true
|
||||
|
||||
// The acquire suspends while the blocking session IPC runs on the
|
||||
// coordinator's queue (never this actor's thread or main).
|
||||
let token: AudioSessionCoordinator.Token
|
||||
do {
|
||||
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
|
||||
Task { await self?.handleSessionInterruption(for: owner) }
|
||||
}
|
||||
} catch {
|
||||
guard activeOwner === owner else {
|
||||
throw CancellationError()
|
||||
}
|
||||
startInFlight = false
|
||||
activeOwner = nil
|
||||
throw error
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
// Actor reentrancy: release/cancel may have ended this hold while the
|
||||
// blocking session activation was still in progress.
|
||||
guard activeOwner === owner, startInFlight else {
|
||||
sessionCoordinator.release(token)
|
||||
throw CancellationError()
|
||||
}
|
||||
startInFlight = false
|
||||
sessionToken = token
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
var outputURL: URL?
|
||||
do {
|
||||
let newURL = try makeOutputURL()
|
||||
outputURL = newURL
|
||||
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
guard audioRecorder.prepareToRecord() else {
|
||||
throw RecorderError.failedToStartRecording
|
||||
}
|
||||
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
|
||||
throw RecorderError.failedToStartRecording
|
||||
}
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = newURL
|
||||
return newURL
|
||||
} catch {
|
||||
releaseSessionToken()
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
if let outputURL {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording(owner: RecordingOwner) async -> URL? {
|
||||
guard activeOwner === owner else { return nil }
|
||||
|
||||
// `finish()` can race a still-suspended start on a direct caller even
|
||||
// though the UI normally routes quick releases through cancel().
|
||||
if startInFlight {
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let activeRecorder = recorder else {
|
||||
let sessionURL = currentURL
|
||||
releaseSessionToken()
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
return sessionURL
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
if activeRecorder.isRecording, paddingInterval > 0 {
|
||||
if let waitForStopPadding = testingHooks.waitForStopPadding {
|
||||
await waitForStopPadding(paddingInterval)
|
||||
} else {
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
// Cancellation or interruption may have run during the padding sleep.
|
||||
// Only the recorder whose stop began here may be finalized by it.
|
||||
guard activeOwner === owner,
|
||||
let recorder = self.recorder,
|
||||
recorder === activeRecorder
|
||||
else { return nil }
|
||||
recorder.stop()
|
||||
|
||||
if activeRecorder.isRecording {
|
||||
activeRecorder.stop()
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
releaseSessionToken()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording(owner: RecordingOwner) async {
|
||||
guard activeOwner === owner else { return }
|
||||
|
||||
// Invalidate ownership before cleanup. An actor-reentrant start whose
|
||||
// session acquire resumes later will observe the mismatch and release
|
||||
// its token without opening the microphone.
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
func cancelRecording() {
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
@@ -246,60 +125,14 @@ actor VoiceRecorder {
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
/// Panic is a synchronous security boundary: the caller must know the
|
||||
/// microphone, audio-session lease, and partial file are gone before it
|
||||
/// rotates identities or deletes the media tree. VoiceRecorder is an
|
||||
/// independent actor and this cleanup path never hops to MainActor, so a
|
||||
/// short semaphore join is safe even when invoked by the UI actor.
|
||||
nonisolated
|
||||
func panicCancelSynchronously(owner: RecordingOwner) {
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
await cancelRecording(owner: owner)
|
||||
finished.signal()
|
||||
}
|
||||
finished.wait()
|
||||
}
|
||||
|
||||
/// The audio session was interrupted (call, Siri) or reconfigured: stop
|
||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||
/// `stopRecording()` still returns the partial note.
|
||||
private func handleSessionInterruption(for owner: RecordingOwner) async {
|
||||
// A callback captured for a released token must never stop a newer
|
||||
// recording. Conversely, an interruption delivered while acquire is
|
||||
// still suspended invalidates that acquire before it can open the mic.
|
||||
guard activeOwner === owner else { return }
|
||||
if startInFlight {
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
return
|
||||
}
|
||||
startInFlight = false
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func hasSystemPermission() -> Bool {
|
||||
#if os(iOS)
|
||||
AVAudioSession.sharedInstance().recordPermission == .granted
|
||||
#elseif os(macOS)
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
|
||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
@@ -314,11 +147,9 @@ actor VoiceRecorder {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
|
||||
/// onto its own queue.
|
||||
private func releaseSessionToken() {
|
||||
guard let token = sessionToken else { return }
|
||||
sessionToken = nil
|
||||
sessionCoordinator.release(token)
|
||||
private func cleanupSession() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ final class WaveformCache {
|
||||
}
|
||||
}
|
||||
|
||||
func purgeAll() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
|
||||
@@ -88,6 +88,8 @@ import BitFoundation
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ enum HandshakeState {
|
||||
case initiated
|
||||
case inProgress
|
||||
case completed(fingerprint: String)
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||
@@ -107,6 +110,7 @@ struct CryptographicIdentity: Codable {
|
||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||
var signingPublicKey: Data? = nil
|
||||
let firstSeen: Date
|
||||
let lastHandshake: Date?
|
||||
}
|
||||
|
||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||
@@ -176,9 +180,9 @@ struct IdentityCache: Codable {
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — decodeIfPresent
|
||||
// is used below, and a missing key must not trip the "unreadable cache"
|
||||
// recovery path that discards everything.
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
@@ -190,49 +194,8 @@ struct IdentityCache: Codable {
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Stable Noise fingerprints that proved encrypted private-media support
|
||||
// inside an authenticated Noise session. Optional for decoding caches
|
||||
// written before this migration. Entries are monotonic until a panic wipe
|
||||
// so an old/replayed announce cannot silently downgrade a peer.
|
||||
var privateMediaCapableFingerprints: Set<String>? = nil
|
||||
|
||||
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
|
||||
// authenticated peer-state payload. This prevents a self-signed announce
|
||||
// containing a copied public Noise key from replacing a previously bound
|
||||
// public-message signing identity. Optional for old cache compatibility.
|
||||
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
|
||||
|
||||
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
|
||||
// Persisting the signing-key pin is security-critical: it must survive
|
||||
// app restarts so an attacker cannot replay a known peer's
|
||||
// noiseKey/peerID with their own signing key and be treated as first
|
||||
// contact (TOFU downgrade).
|
||||
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
|
||||
init() {}
|
||||
|
||||
// Custom decoding so caches written by older builds (missing newer keys
|
||||
// such as `cryptographicIdentities` or the vouching fields) still load
|
||||
// instead of being discarded. Every field uses decodeIfPresent so a
|
||||
// missing key falls back to its default rather than throwing.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
|
||||
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
|
||||
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
|
||||
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
|
||||
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
|
||||
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
|
||||
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
|
||||
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
|
||||
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
|
||||
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
|
||||
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -108,6 +108,8 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
func getFavorites() -> Set<String>
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
// MARK: Blocked Users Management
|
||||
@@ -121,6 +123,7 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
@@ -136,18 +139,11 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||
func isVouched(fingerprint: String) -> Bool
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
|
||||
// MARK: Noise-authenticated announcement identity
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
|
||||
// MARK: Private-media downgrade protection
|
||||
func markPrivateMediaCapable(fingerprint: String)
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
@@ -160,30 +156,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
// Cryptographic identities (including pinned signing keys) live inside
|
||||
// `cache` so they persist across app restarts; see IdentityCache.
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
//
|
||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
||||
// returns the write is already complete and NOTHING is left scheduled on
|
||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
||||
// original design) kept the dispatch machinery alive and prevented the
|
||||
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
|
||||
// (a later design) left a backlog of instrumented barrier saves still
|
||||
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
|
||||
// `.profraw`, deadlocking the process at teardown on the constrained CI
|
||||
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
|
||||
// neither failure mode is possible. `pendingSave` is now effectively always
|
||||
// false after any mutation (saveIdentityCache persists inline and clears
|
||||
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
|
||||
// and `deinit`.
|
||||
// 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.)
|
||||
private var pendingSave = false
|
||||
|
||||
// Encryption key
|
||||
@@ -234,7 +218,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
queue.setSpecific(key: queueSpecificKey, value: 1)
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
@@ -244,22 +227,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
||||
// (including one draining `queue`), and the object is being
|
||||
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
|
||||
// (deadlock) and a `queue.async` schedules work that resurrects `self`
|
||||
// and may not drain before process exit.
|
||||
//
|
||||
// A flush here is redundant anyway: every mutating API already
|
||||
// persists inline within its own barrier, so the keychain is already
|
||||
// up to date. As a queue-free best-effort belt-and-suspenders, only
|
||||
// flush if something is still pending. This is a direct read of
|
||||
// in-hand state — safe because a deallocating object has no other
|
||||
// live references, so nothing can be mutating `cache` concurrently.
|
||||
if pendingSave {
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
forceSave()
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
@@ -284,27 +252,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
||||
/// while serialized. The encode + keychain write are done here (already on
|
||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
||||
/// scheduled and nothing is left to keep the process alive.
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
pendingSave = true
|
||||
// On the barrier context already: snapshot is trivially consistent.
|
||||
persist(snapshot: cache)
|
||||
pendingSave = false
|
||||
performSave()
|
||||
}
|
||||
|
||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
||||
///
|
||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
||||
/// under `queue` and then encode without holding it. Reading `cache`
|
||||
/// concurrently with a barrier writer would be a data race on the
|
||||
/// dictionary storage, which — because `JSONEncoder` walks that storage —
|
||||
/// can spin forever (observed as a CI test-suite hang), so the snapshot
|
||||
/// must be taken on `queue`, never off it.
|
||||
private func persist(snapshot: IdentityCache) {
|
||||
/// 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 {
|
||||
@@ -313,7 +275,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
@@ -324,26 +286,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
||||
// API already persists inline inside its own barrier via
|
||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
||||
// already up to date and this is normally a no-op; it exists as a
|
||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
||||
//
|
||||
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
|
||||
// makes the `cache` read race-free (a plain off-queue read races in-flight
|
||||
// barrier writers — JSONEncoder walking a concurrently-mutated dictionary
|
||||
// can spin forever, which surfaced as a CI hang), and being synchronous it
|
||||
// leaves nothing scheduled to keep the process alive at teardown. Safe
|
||||
// against re-entrant deadlock because this is never invoked from `deinit`
|
||||
// (the only path that can run *on* `queue`).
|
||||
// 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.
|
||||
func forceSave() {
|
||||
queue.sync(flags: .barrier) {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
performSave()
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@@ -357,46 +307,36 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been persisted for a
|
||||
/// fingerprint, an update carrying a *different* signing key is refused in
|
||||
/// full (including the claimed-nickname update) and security-logged. This
|
||||
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an
|
||||
/// attacker replaying a victim's noiseKey/peerID with their own signing
|
||||
/// key could overwrite the victim's persisted identity while the victim is
|
||||
/// offline or after an app restart. The refusal is permanent: there is
|
||||
/// currently no targeted in-app way to reset the pin (`setVerified` does
|
||||
/// not touch it). Recovering from a legitimate signing re-key requires the
|
||||
/// peer to establish a new noise identity (new peerID) or the local user
|
||||
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
||||
if let pinnedSigningKey = existing.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
|
||||
return
|
||||
}
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
let updated = CryptographicIdentity(
|
||||
fingerprint: existing.fingerprint,
|
||||
publicKey: existing.publicKey,
|
||||
signingPublicKey: existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = updated
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
@@ -405,9 +345,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
@@ -439,72 +380,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private-media downgrade protection
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
guard !fingerprint.isEmpty else { return }
|
||||
let insertAndPersist = {
|
||||
var pinned = self.cache.privateMediaCapableFingerprints ?? []
|
||||
guard pinned.insert(fingerprint).inserted else { return }
|
||||
self.cache.privateMediaCapableFingerprints = pinned
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
// Downgrade decisions can run immediately after an authenticated
|
||||
// announce. Make the pin visible before returning; merely enqueueing a
|
||||
// barrier leaves a cross-queue window where a replay can look legacy.
|
||||
// The queue-specific fast path prevents self-deadlock if a future
|
||||
// identity-state mutation records the capability from inside `queue`.
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
insertAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: insertAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
guard !fingerprint.isEmpty else { return false }
|
||||
return queue.sync {
|
||||
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise-authenticated announcement identity
|
||||
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
|
||||
!fingerprint.isEmpty else { return }
|
||||
let bindAndPersist = {
|
||||
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
|
||||
let bindingChanged = bindings[fingerprint] != signingPublicKey
|
||||
bindings[fingerprint] = signingPublicKey
|
||||
self.cache.authenticatedSigningKeysByFingerprint = bindings
|
||||
if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] {
|
||||
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||
self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||
}
|
||||
guard bindingChanged else { return }
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
bindAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: bindAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
guard !fingerprint.isEmpty else { return nil }
|
||||
return queue.sync {
|
||||
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
@@ -540,7 +421,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isFavorite = isFavorite
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
@@ -578,7 +459,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
if isBlocked {
|
||||
@@ -612,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
let key = pubkeyHexLowercased.lowercased()
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if isBlocked {
|
||||
self.cache.blockedNostrPubkeys.insert(key)
|
||||
} else {
|
||||
@@ -630,12 +511,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
sessionStart: Date(),
|
||||
handshakeState: handshakeState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
// If handshake completed, update last interaction
|
||||
@@ -651,9 +536,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
self.cryptographicIdentities.removeAll()
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
@@ -662,7 +548,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
@@ -672,7 +558,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
@@ -840,7 +726,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
|
||||
@@ -31,20 +31,24 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_bitchat-bulk._tcp</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
@@ -13,6 +13,13 @@ extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
|
||||
@@ -7,6 +7,7 @@ struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
|
||||
@@ -76,13 +77,14 @@ struct BitchatPeer: Equatable {
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen _: Date = Date(),
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ enum CommandInfo: String, Identifiable {
|
||||
case unfavorite = "unfav"
|
||||
case ping
|
||||
case trace
|
||||
case drop
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -42,8 +41,6 @@ enum CommandInfo: String, Identifiable {
|
||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||
case .drop:
|
||||
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
@@ -65,12 +62,11 @@ enum CommandInfo: String, Identifiable {
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .ping: String(localized: "content.commands.ping")
|
||||
case .trace: String(localized: "content.commands.trace")
|
||||
case .drop: String(localized: "content.commands.drop")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||
// processor still allows it behind an explicit "public" confirm).
|
||||
@@ -78,11 +74,11 @@ enum CommandInfo: String, Identifiable {
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they work: mesh.
|
||||
// The processor rejects favorites, groups and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ struct NoisePayload {
|
||||
|
||||
// Safely get the first byte
|
||||
let firstByte = data[data.startIndex]
|
||||
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
|
||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
|
||||
return messageBuffer
|
||||
}
|
||||
|
||||
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
|
||||
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
|
||||
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
|
||||
@@ -6,67 +6,27 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
|
||||
/// added by `NoiseCipherState` around every transport plaintext.
|
||||
static let transportCiphertextOverhead = 20
|
||||
|
||||
/// Private files are an explicit BitChat extension to the ordinary Noise
|
||||
/// message-size ceiling. They remain bounded by the same framed-file cap
|
||||
/// used by the binary and fragment decoders. Only the `.privateFile`
|
||||
/// typed-payload path is allowed to use this larger budget.
|
||||
private static let privateFileOuterPacketOverhead =
|
||||
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
|
||||
+ BinaryProtocol.senderIDSize
|
||||
+ BinaryProtocol.recipientIDSize
|
||||
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
|
||||
- privateFileOuterPacketOverhead
|
||||
- transportCiphertextOverhead
|
||||
static let maxPrivateFileCiphertextSize =
|
||||
maxPrivateFilePlaintextSize + transportCiphertextOverhead
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Bounds an ordinary initiator whose message 1 or 2 is lost.
|
||||
static let ordinaryHandshakeTimeout: TimeInterval = 10
|
||||
|
||||
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
||||
|
||||
// A released client may immediately retry after both crossed initiators
|
||||
// yielded. Give that unilateral retry a brief head start before the
|
||||
// patched side spends its one bounded recovery.
|
||||
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
|
||||
|
||||
// Rate-limited recovery remains actionable without spinning.
|
||||
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
|
||||
|
||||
// Covers only reordering between a winning message 3 and the losing
|
||||
// crossed message 1.
|
||||
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
|
||||
|
||||
// After unauthenticated responder rollback, reject another attempt long
|
||||
// enough that paced message 1 traffic cannot keep outbound paused. A
|
||||
// legitimate peer converges through the one manager-owned local retry.
|
||||
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
@@ -14,4 +14,5 @@ enum NoiseSecurityError: Error {
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
|
||||
@@ -15,19 +15,6 @@ struct NoiseSecurityValidator {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
static func validateCiphertextSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
}
|
||||
|
||||
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
|
||||
}
|
||||
|
||||
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
|
||||
@@ -66,10 +66,7 @@ class NoiseSession {
|
||||
|
||||
// Only initiator writes the first message
|
||||
if role == .initiator {
|
||||
guard let handshake = handshakeState else {
|
||||
throw NoiseSessionError.invalidState
|
||||
}
|
||||
let message = try handshake.writeMessage()
|
||||
let message = try handshakeState!.writeMessage()
|
||||
sentHandshakeMessages.append(message)
|
||||
return message
|
||||
} else {
|
||||
|
||||
@@ -11,11 +11,4 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||
/// must not launch its historical second, immediate restart for this failure.
|
||||
struct NoiseManagedHandshakeFailure: Error {
|
||||
let underlying: Error
|
||||
}
|
||||
|
||||
@@ -24,12 +24,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Ordinary Noise messages keep the protocol ceiling. Finalized media
|
||||
// is the sole typed-payload extension and remains under the framed-file
|
||||
// cap enforced again at the service and file-decoder layers.
|
||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
|
||||
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -46,11 +42,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// The payload type is encrypted, so a large candidate can only be
|
||||
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and
|
||||
// then requires the resulting type to be `.privateFile`.
|
||||
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
|
||||
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
var autoStart: Bool
|
||||
var validationPolicy: GeoRelayDirectoryValidationPolicy
|
||||
}
|
||||
|
||||
struct GeoRelayDirectoryValidationPolicy: Sendable {
|
||||
let maximumBytes: Int
|
||||
let maximumRows: Int
|
||||
let maximumEntries: Int
|
||||
let minimumRemoteEntries: Int
|
||||
let minimumRetainedFraction: Double
|
||||
|
||||
static let live = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 512 * 1024,
|
||||
maximumRows: 5_000,
|
||||
maximumEntries: 5_000,
|
||||
minimumRemoteEntries: 50,
|
||||
minimumRetainedFraction: 0.5
|
||||
)
|
||||
}
|
||||
|
||||
private extension GeoRelayDirectoryDependencies {
|
||||
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
// Runtime refreshes only from bitchat's reviewed copy. Upstream
|
||||
// georelays/main is imported by a validator-backed pull request,
|
||||
// so an upstream mutation cannot immediately retarget clients.
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200...299).contains(response.statusCode),
|
||||
response.url == request.url else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let maximumBytes = validationPolicy.maximumBytes
|
||||
guard response.expectedContentLength <= Int64(maximumBytes) else {
|
||||
throw URLError(.dataLengthExceedsMaximum)
|
||||
}
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 {
|
||||
data.reserveCapacity(Int(response.expectedContentLength))
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < maximumBytes else {
|
||||
throw URLError(.dataLengthExceedsMaximum)
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
// v2 ignores caches populated from the old direct-upstream
|
||||
// trust path and subjects every load to strict validation.
|
||||
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
|
||||
try? FileManager.default.removeItem(at: legacyCache)
|
||||
return dir.appendingPathComponent("georelays_cache_v2.csv")
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true,
|
||||
validationPolicy: validationPolicy
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: Data)
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
let validationPolicy = dependencies.validationPolicy
|
||||
let baselineEntries = Set(entries)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData,
|
||||
validationPolicy: validationPolicy,
|
||||
baselineEntries: baselineEntries
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy,
|
||||
baselineEntries: Set<Entry>
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let parsed = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: validationPolicy,
|
||||
minimumEntries: validationPolicy.minimumRemoteEntries,
|
||||
baselineEntries: baselineEntries
|
||||
) else {
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: data)
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ data: Data) {
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
|
||||
// Prefer cached file if present
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = dependencies.readData(url),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
||||
/// One malformed or conflicting row rejects the complete dataset rather
|
||||
/// than silently shrinking or partially replacing the current directory.
|
||||
nonisolated static func validatedEntries(
|
||||
from data: Data,
|
||||
policy: GeoRelayDirectoryValidationPolicy,
|
||||
minimumEntries: Int,
|
||||
baselineEntries: Set<Entry>? = nil
|
||||
) -> [Entry]? {
|
||||
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
!text.hasPrefix("\u{feff}") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let header = lines.first,
|
||||
lines.count - 1 <= policy.maximumRows else {
|
||||
return nil
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
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 }
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
|
||||
let headerParts = header
|
||||
.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||
let supportedHeaders = [
|
||||
["relay url", "latitude", "longitude"],
|
||||
["relay url", "lat", "lon"]
|
||||
]
|
||||
guard supportedHeaders.contains(headerParts) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entriesByHost: [String: Entry] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
let parts = line
|
||||
.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard parts.count == 3,
|
||||
let host = validatedDirectoryAddress(parts[0]),
|
||||
let latitude = Double(parts[1]), latitude.isFinite,
|
||||
(-90.0...90.0).contains(latitude),
|
||||
let longitude = Double(parts[2]), longitude.isFinite,
|
||||
(-180.0...180.0).contains(longitude) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let entry = Entry(host: host, lat: latitude, lon: longitude)
|
||||
if let existing = entriesByHost[host], existing != entry {
|
||||
// One endpoint cannot truthfully occupy two coordinates. Do
|
||||
// not let row ordering choose which location clients trust.
|
||||
return nil
|
||||
}
|
||||
entriesByHost[host] = entry
|
||||
guard entriesByHost.count <= policy.maximumEntries else { return nil }
|
||||
}
|
||||
|
||||
let parsedEntries = Set(entriesByHost.values)
|
||||
guard parsedEntries.count >= minimumEntries else { return nil }
|
||||
|
||||
if let baselineEntries {
|
||||
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
|
||||
let requiredOverlap = Int(
|
||||
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
|
||||
)
|
||||
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return parsedEntries.sorted {
|
||||
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty,
|
||||
value.unicodeScalars.allSatisfy({
|
||||
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidate = value.contains("://") ? value : "wss://\(value)"
|
||||
guard let components = URLComponents(string: candidate),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "wss" || scheme == "https",
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
components.query == nil,
|
||||
components.fragment == nil,
|
||||
components.path.isEmpty || components.path == "/",
|
||||
let rawHost = components.host else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let host = rawHost.lowercased()
|
||||
guard !host.isEmpty, host.count <= 253,
|
||||
host.unicodeScalars.allSatisfy({ $0.isASCII }),
|
||||
!host.hasSuffix("."),
|
||||
host != "localhost",
|
||||
!host.hasSuffix(".localhost"),
|
||||
!host.hasSuffix(".local"),
|
||||
!host.hasSuffix(".internal") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
|
||||
guard labels.count >= 2,
|
||||
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
|
||||
labels.allSatisfy({ label in
|
||||
(1...63).contains(label.count) &&
|
||||
label.first != "-" &&
|
||||
label.last != "-" &&
|
||||
label.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let port = components.port {
|
||||
guard (1...65_535).contains(port) else { return nil }
|
||||
if port != 443 { return "\(host):\(port)" }
|
||||
}
|
||||
return host
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
|
||||
@@ -6,12 +6,14 @@ struct NostrIdentity: Codable {
|
||||
let privateKey: Data
|
||||
let publicKey: Data
|
||||
let npub: String // Bech32-encoded public key
|
||||
let createdAt: Date
|
||||
|
||||
/// Memberwise initializer
|
||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
|
||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
||||
self.privateKey = privateKey
|
||||
self.publicKey = publicKey
|
||||
self.npub = npub
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// Generate a new Nostr identity
|
||||
@@ -37,6 +39,12 @@ struct NostrIdentity: Codable {
|
||||
self.privateKey = privateKeyData
|
||||
self.publicKey = xOnlyPubkey
|
||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
/// Get signing key for event signatures
|
||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
||||
}
|
||||
|
||||
/// Get Schnorr signing key for Nostr event signatures
|
||||
|
||||
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ final class NostrIdentityBridge {
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
@@ -49,10 +57,29 @@ final class NostrIdentityBridge {
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
// Must go through the injected keychain, not raw SecItem calls:
|
||||
// under test that keychain is in-memory, and a direct delete here
|
||||
// would wipe the developer's real Nostr identity on every test run.
|
||||
keychain.deleteAll(service: keychainService)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||
@@ -86,13 +113,6 @@ final class NostrIdentityBridge {
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
|
||||
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
|
||||
/// geohash-chat identity for the same cell string.
|
||||
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
|
||||
try deriveIdentity(forGeohash: "bridge|" + cell)
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
|
||||
@@ -19,11 +19,6 @@ struct NostrProtocol {
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
/// Sealed courier envelope parked on relays under its rotating
|
||||
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||
case courierDrop = 1401
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -260,115 +255,17 @@ struct NostrProtocol {
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
// MARK: - Mesh bridge (rendezvous) events
|
||||
|
||||
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
||||
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
||||
/// channel subscriptions (which filter on `#g`); `m` is
|
||||
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
|
||||
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
|
||||
/// parsers, which key their dedup on `m[1]` unconditionally and need it
|
||||
/// per-message-unique. Current parsers key bridge rows by the authenticated
|
||||
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
|
||||
/// coordinates are public and cannot authenticate the Nostr signer.
|
||||
static func createBridgeMeshEvent(
|
||||
content: String,
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
meshSenderID: String? = nil,
|
||||
meshTimestampMs: UInt64? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["r", cell]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
|
||||
let stableID = MeshMessageIdentity.stableID(
|
||||
senderIDHex: meshSenderID,
|
||||
timestampMs: meshTimestampMs,
|
||||
content: content
|
||||
)
|
||||
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
|
||||
/// cell: empty content, `r` tag only — the bridge analogue of geohash
|
||||
/// presence, counted into "people across the bridge".
|
||||
static func createBridgePresenceEvent(
|
||||
cell: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["r", cell]],
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
|
||||
/// parked on relays. `x` is the hex recipient tag the recipient (or a
|
||||
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
|
||||
/// the envelope expiry so honoring relays garbage-collect the drop. The
|
||||
/// signing identity should be a throwaway — the envelope authenticates
|
||||
/// its sender internally via Noise-X, and linking drops to a stable
|
||||
/// publisher key would leak courier traffic patterns.
|
||||
static func createCourierDropEvent(
|
||||
envelope: Data,
|
||||
recipientTagHex: String,
|
||||
expiresAt: Date,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [
|
||||
["x", recipientTagHex],
|
||||
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
|
||||
]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .courierDrop,
|
||||
tags: tags,
|
||||
content: envelope.base64EncodedString()
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
||||
/// drop the note in step with a bridged board post's expiry.
|
||||
static func createGeohashTextNote(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
expiresAt: Date? = nil,
|
||||
urgent: Bool = false
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if let expiresAt {
|
||||
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
||||
}
|
||||
if urgent {
|
||||
tags.append(["t", "urgent"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
@@ -380,24 +277,6 @@ struct NostrProtocol {
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
||||
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
||||
/// key that signed the original.
|
||||
static func createDeleteEvent(
|
||||
ofEventID eventID: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .deletion,
|
||||
tags: [["e", eventID]],
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private static func createSeal(
|
||||
@@ -644,6 +523,37 @@ struct NostrProtocol {
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
// Direct version that doesn't try to add prefixes
|
||||
private static func deriveSharedSecretDirect(
|
||||
privateKey: P256K.Schnorr.PrivateKey,
|
||||
publicKey: Data
|
||||
) throws -> Data {
|
||||
// Direct shared secret calculation
|
||||
|
||||
// Convert Schnorr private key to KeyAgreement private key
|
||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
||||
dataRepresentation: privateKey.dataRepresentation
|
||||
)
|
||||
|
||||
// Use the public key as-is (should already have prefix)
|
||||
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||
dataRepresentation: publicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Perform ECDH
|
||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||
with: keyAgreementPublicKey,
|
||||
format: .compressed
|
||||
)
|
||||
|
||||
// Convert SharedSecret to Data
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
// Add random offset to current time for privacy
|
||||
// This prevents timing correlation attacks while the actual message timestamp
|
||||
@@ -701,10 +611,6 @@ struct NostrEvent: Codable {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
guard Self.isWithinInboundTagLimits(tags) else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
self.id = dict["id"] as? String ?? ""
|
||||
self.pubkey = pubkey
|
||||
self.created_at = createdAt
|
||||
@@ -714,21 +620,6 @@ struct NostrEvent: Codable {
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
/// Bounds untrusted relay tag arrays so attackers cannot force large
|
||||
/// allocations or expensive joins on the inbound hot path.
|
||||
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||
|
||||
for tag in tags {
|
||||
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
|
||||
@@ -792,8 +683,11 @@ struct NostrEvent: Codable {
|
||||
|
||||
enum NostrError: Error {
|
||||
case invalidPublicKey
|
||||
case invalidPrivateKey
|
||||
case invalidEvent
|
||||
case invalidCiphertext
|
||||
case signingFailed
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
|
||||
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
let base: URLSession
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
let task = base.webSocketTask(with: url)
|
||||
// Byte bound per inbound frame; without it the per-relay buffer cap
|
||||
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
|
||||
// hostile relay could pile up cap × 1 MiB (URLSession default) per
|
||||
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
|
||||
// sizing rationale. Oversized frames fail the receive with an error.
|
||||
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
|
||||
return URLSessionWebSocketTaskAdapter(base: task)
|
||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
|
||||
// Entries are removed only on OK acks (or panic wipe); relays that never
|
||||
// ack leave entries behind for the process lifetime. Observability-only
|
||||
// state, bounded in practice by outbound DM volume.
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
@@ -127,6 +117,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
let url: String
|
||||
var isConnected: Bool = false
|
||||
var lastError: Error?
|
||||
var lastConnectedAt: Date?
|
||||
var messagesSent: Int = 0
|
||||
var messagesReceived: Int = 0
|
||||
var reconnectAttempts: Int = 0
|
||||
@@ -193,26 +184,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||
|
||||
// Track EOSE per subscription to signal when initial stored events are
|
||||
// done. Completion is scoped to relays the REQ actually reached: targets
|
||||
// still mid-connect must not hold the callback hostage until the fallback
|
||||
// timer (a dead relay of five used to pin "loading" for the full 10s).
|
||||
// Track EOSE per subscription to signal when initial stored events are done
|
||||
private struct EOSETracker {
|
||||
/// Targets the REQ has not been delivered to yet (still connecting).
|
||||
var awaitingSend: Set<String>
|
||||
/// Relays that received the REQ and have not sent EOSE yet.
|
||||
var awaitingEOSE: Set<String>
|
||||
/// True once any relay received the REQ (or answered with EOSE) —
|
||||
/// completion with zero sends would mean "done" without ever asking.
|
||||
var didSend = false
|
||||
var pendingRelays: Set<String>
|
||||
var callback: () -> Void
|
||||
let epoch: Int
|
||||
|
||||
/// Done when every relay that got the REQ has resolved, provided at
|
||||
/// least one did — or when every target dropped out entirely.
|
||||
var isComplete: Bool {
|
||||
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
|
||||
}
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
private var eoseTrackerEpoch = 0
|
||||
@@ -226,15 +202,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
||||
/// accept/reject acknowledgment.
|
||||
private struct ConfirmedSendState {
|
||||
let token: UUID
|
||||
var awaitingRelays: Set<String>
|
||||
let completion: (Bool) -> Void
|
||||
}
|
||||
private var confirmedSends: [String: ConfirmedSendState] = [:]
|
||||
// Total pending sends dropped at the queue cap; drives the sampled
|
||||
// overflow warning (first + every Nth drop).
|
||||
private var pendingSendDropCount = 0
|
||||
@@ -250,32 +217,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
|
||||
// Schnorr-verified in arrival order OFF the main actor (this is the single
|
||||
// signature verification for the whole inbound path — downstream handlers
|
||||
// receive only verified events), then hop back to the main actor for dedup
|
||||
// recording and handler dispatch.
|
||||
//
|
||||
// Each relay connection owns its OWN AsyncStream + consumer task, so N
|
||||
// relays verify in parallel while every relay's frames stay in arrival
|
||||
// order (a single subscription's events for a relay all arrive on that
|
||||
// relay's socket, so per-relay ordering preserves per-subscription
|
||||
// ordering). A burst of EVENT frames from one busy/malicious relay only
|
||||
// blocks that relay's own verification backlog — DMs, OKs, EOSEs, and
|
||||
// events from every other relay keep flowing on their own pipelines.
|
||||
//
|
||||
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
|
||||
// than its verification drains sheds its own oldest frames instead of
|
||||
// growing memory without bound; it can never starve other relays.
|
||||
//
|
||||
// Continuations live in a lock-guarded, `Sendable` router (see
|
||||
// `InboundFrameRouter` at file scope) so the raw socket receive callback
|
||||
// (which is NOT main-actor isolated) can route a frame to the right relay
|
||||
// stream without a per-frame main hop, while the main actor owns pipeline
|
||||
// creation/teardown. The expensive work (Schnorr verify) is what runs
|
||||
// off-main; the yield stays cheap.
|
||||
private let inboundRouter = InboundFrameRouter()
|
||||
|
||||
init() {
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
@@ -330,69 +271,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit {
|
||||
inboundRouter.finishAll()
|
||||
}
|
||||
|
||||
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
|
||||
/// the main actor when a socket is (re)armed for receiving. Idempotent.
|
||||
///
|
||||
/// Ordering within the relay is deliberate and security/performance-critical:
|
||||
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
|
||||
/// duplicate LOOKUP — duplicate fan-in from multiple relays dominates
|
||||
/// real traffic and must never pay for Schnorr verification.
|
||||
/// 2. `isValidSignature()` runs here, off the main actor — the ONLY
|
||||
/// signature verification on the inbound path (JSON re-serialization +
|
||||
/// SHA-256 + secp256k1 Schnorr per event).
|
||||
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
|
||||
/// check-and-RECORD plus handler dispatch. Recording only after
|
||||
/// verification means a forged-signature copy can never poison the
|
||||
/// dedup cache and suppress the genuine event.
|
||||
private func ensureRelayInboundPipeline(for relayUrl: String) {
|
||||
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
|
||||
Task.detached(priority: .userInitiated) {
|
||||
for await frame in stream {
|
||||
guard let parsed = ParsedInbound(frame.message) else { continue }
|
||||
guard let self else { return }
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
guard await self.precheckInboundEvent(
|
||||
subscriptionID: subId,
|
||||
eventID: event.id,
|
||||
relayUrl: relayUrl
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
continue
|
||||
}
|
||||
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
|
||||
case .eose, .ok, .notice:
|
||||
await self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
|
||||
/// consumer drains any already-buffered frames before finishing, so
|
||||
/// in-flight verified events are still delivered.
|
||||
private func teardownRelayInboundPipeline(for relayUrl: String) {
|
||||
inboundRouter.finishPipeline(for: relayUrl)
|
||||
}
|
||||
|
||||
private func teardownAllRelayInboundPipelines() {
|
||||
inboundRouter.finishAll()
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
@@ -407,8 +285,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Sockets are gone; drop every relay's inbound verify pipeline.
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: false)
|
||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||
@@ -422,9 +298,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
for (_, tracker) in trackers {
|
||||
tracker.callback()
|
||||
}
|
||||
let confirmed = confirmedSends.values.map(\.completion)
|
||||
confirmedSends.removeAll()
|
||||
confirmed.forEach { $0(false) }
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
torReadyWaitAttempts = 0
|
||||
@@ -441,7 +314,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: true)
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
@@ -459,7 +331,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
confirmedSends.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
messageQueue.removeAll()
|
||||
@@ -476,6 +347,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].nextReconnectTime = nil
|
||||
if resetState {
|
||||
relays[index].lastError = nil
|
||||
relays[index].lastConnectedAt = nil
|
||||
relays[index].lastDisconnectedAt = nil
|
||||
relays[index].messagesSent = 0
|
||||
relays[index].messagesReceived = 0
|
||||
@@ -534,97 +406,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts an event only on currently connected target relays and
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-20
|
||||
/// OK. A successful WebSocket write alone is not durable acceptance.
|
||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||
/// callers use it when success unlocks durable state or user-visible
|
||||
/// delivery progress.
|
||||
func sendEventImmediately(
|
||||
_ event: NostrEvent,
|
||||
to relayUrls: [String]? = nil,
|
||||
completion: @escaping (Bool) -> Void
|
||||
) {
|
||||
guard dependencies.activationAllowed() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
|
||||
guard let connection = connectedConnection(for: relayUrl) else { return nil }
|
||||
return (relayUrl, connection)
|
||||
}
|
||||
guard !connectedTargets.isEmpty else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
let token = UUID()
|
||||
let eventID = event.id
|
||||
if let replaced = confirmedSends.removeValue(forKey: eventID) {
|
||||
replaced.completion(false)
|
||||
}
|
||||
confirmedSends[eventID] = ConfirmedSendState(
|
||||
token: token,
|
||||
awaitingRelays: Set(connectedTargets.map(\.0)),
|
||||
completion: completion
|
||||
)
|
||||
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.timeoutConfirmedSend(eventID: eventID, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
for (relayUrl, connection) in connectedTargets {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
|
||||
guard let self else { return }
|
||||
// Success only means the bytes reached the socket; wait for
|
||||
// the matching relay OK. A failed write settles this target
|
||||
// as rejected because no OK can arrive for it.
|
||||
if !succeeded {
|
||||
self.resolveConfirmedSend(
|
||||
eventID: eventID,
|
||||
relayURL: relayUrl,
|
||||
accepted: false,
|
||||
token: token
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveConfirmedSend(
|
||||
eventID: String,
|
||||
relayURL: String,
|
||||
accepted: Bool,
|
||||
token: UUID? = nil
|
||||
) {
|
||||
guard var state = confirmedSends[eventID],
|
||||
token == nil || state.token == token,
|
||||
state.awaitingRelays.remove(relayURL) != nil else { return }
|
||||
if accepted {
|
||||
confirmedSends.removeValue(forKey: eventID)
|
||||
state.completion(true)
|
||||
} else if state.awaitingRelays.isEmpty {
|
||||
confirmedSends.removeValue(forKey: eventID)
|
||||
state.completion(false)
|
||||
} else {
|
||||
confirmedSends[eventID] = state
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutConfirmedSend(eventID: String, token: UUID) {
|
||||
guard let state = confirmedSends[eventID], state.token == token else { return }
|
||||
confirmedSends.removeValue(forKey: eventID)
|
||||
state.completion(false)
|
||||
}
|
||||
|
||||
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||
@@ -783,7 +564,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
teardownRelayInboundPipeline(for: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
@@ -1015,7 +795,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||
eoseTrackerEpoch += 1
|
||||
let epoch = eoseTrackerEpoch
|
||||
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
|
||||
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -1124,17 +904,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
connections[urlString] = task
|
||||
task.resume()
|
||||
|
||||
// Bring up this relay's own serial verify pipeline before arming the
|
||||
// socket, so inbound frames have somewhere to land.
|
||||
ensureRelayInboundPipeline(for: urlString)
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessage(from: task, relayUrl: urlString)
|
||||
|
||||
// Send initial ping to verify connection
|
||||
task.sendPing { [weak self] error in
|
||||
DispatchQueue.main.async {
|
||||
guard self?.connections[urlString] === task else { return }
|
||||
if error == nil {
|
||||
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
||||
self?.updateRelayStatus(urlString, isConnected: true)
|
||||
@@ -1144,11 +919,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
|
||||
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
||||
// Trigger disconnection handler for proper backoff
|
||||
self?.handleDisconnection(
|
||||
relayUrl: urlString,
|
||||
error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil),
|
||||
connection: task
|
||||
)
|
||||
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1164,19 +935,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
toSend[id] = state.messageString
|
||||
}
|
||||
for (id, messageString) in toSend {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true {
|
||||
// Already subscribed on this relay (e.g. a tracker promoted
|
||||
// after an earlier flush): its EOSE is coming, count it.
|
||||
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||
continue
|
||||
}
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
startPendingEOSETrackingIfNeeded(id: id)
|
||||
// Mark at send *initiation*, not in the async completion: a fast
|
||||
// relay's EOSE could otherwise complete the tracker while this
|
||||
// relay — REQ already on the wire — still sat in awaitingSend.
|
||||
// If the send fails the socket is going down with it, and the
|
||||
// disconnect settle (or the fallback timer) releases the wait.
|
||||
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
||||
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -1202,23 +962,22 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
// Hand the raw frame to this relay's serial inbound pipeline:
|
||||
// parsing and signature verification run off-main, in arrival
|
||||
// order, independently of every other relay's pipeline. Routing
|
||||
// through the lock-guarded router keeps this off the main actor
|
||||
// (no per-frame main hop).
|
||||
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
|
||||
|
||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue receiving
|
||||
Task { @MainActor in
|
||||
guard self.connections[relayUrl] === task else { return }
|
||||
self.receiveMessage(from: task, relayUrl: relayUrl)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task)
|
||||
self.handleDisconnection(relayUrl: relayUrl, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1228,63 +987,39 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||
// and keep parsing off the main actor.
|
||||
|
||||
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
|
||||
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
|
||||
/// relays never pays for Schnorr verification. Recording happens only
|
||||
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
|
||||
/// forged-signature copy can never poison the dedup cache and suppress
|
||||
/// the genuine event.
|
||||
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
|
||||
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
relays[index].messagesReceived += 1
|
||||
}
|
||||
guard !eventID.isEmpty else { return true }
|
||||
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
|
||||
if recentInboundEventKeys.contains(key) {
|
||||
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Second main-actor hop, after off-main signature verification:
|
||||
/// authoritative check-and-record (the serial pipeline means the same
|
||||
/// event is never in flight twice, but the record must stay atomic with
|
||||
/// delivery) and handler dispatch.
|
||||
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
|
||||
// Handle parsed message on MainActor (state updates and handlers)
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event:
|
||||
// Events flow through the serial inbound pipeline (precheck →
|
||||
// off-main signature verification → deliverVerifiedInboundEvent)
|
||||
// and never reach this fallback.
|
||||
assertionFailure("inbound EVENT bypassed the verified pipeline")
|
||||
case .event(let subId, let event):
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self.relays[index].messagesReceived += 1
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
case .eose(let subId):
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
// An EOSE proves the relay received the REQ even if the local
|
||||
// send completion hasn't run yet.
|
||||
tracker.awaitingSend.remove(relayUrl)
|
||||
tracker.awaitingEOSE.remove(relayUrl)
|
||||
tracker.didSend = true
|
||||
if tracker.isComplete {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
if tracker.pendingRelays.isEmpty {
|
||||
eoseTrackers.removeValue(forKey: subId)
|
||||
tracker.callback()
|
||||
} else {
|
||||
@@ -1292,7 +1027,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
case .ok(let eventId, let success, let reason):
|
||||
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||
if success {
|
||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
@@ -1309,12 +1043,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendToRelay(
|
||||
event: NostrEvent,
|
||||
connection: NostrRelayConnectionProtocol,
|
||||
relayUrl: String,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||
let req = NostrRequest.event(event)
|
||||
|
||||
do {
|
||||
@@ -1327,20 +1056,17 @@ final class NostrRelayManager: ObservableObject {
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
||||
completion?(false)
|
||||
} else {
|
||||
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
||||
// Update relay stats
|
||||
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self?.relays[index].messagesSent += 1
|
||||
}
|
||||
completion?(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1349,6 +1075,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].isConnected = isConnected
|
||||
relays[index].lastError = error
|
||||
if isConnected {
|
||||
relays[index].lastConnectedAt = dependencies.now()
|
||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
@@ -1373,11 +1100,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// callbacks; treat it as done and let the remaining relays (or the
|
||||
/// fallback timeout) drive completion.
|
||||
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
||||
for (id, var tracker) in eoseTrackers
|
||||
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
|
||||
tracker.awaitingSend.remove(relayUrl)
|
||||
tracker.awaitingEOSE.remove(relayUrl)
|
||||
if tracker.isComplete {
|
||||
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
if tracker.pendingRelays.isEmpty {
|
||||
eoseTrackers.removeValue(forKey: id)
|
||||
tracker.callback()
|
||||
} else {
|
||||
@@ -1386,39 +1111,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any of `relayUrls` currently holds a live connection. Lets
|
||||
/// subscribers distinguish "loaded, empty" from "never reached a relay"
|
||||
/// when an EOSE fallback fires.
|
||||
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
|
||||
let targets = Set(relayUrls)
|
||||
return relays.contains { targets.contains($0.url) && $0.isConnected }
|
||||
}
|
||||
|
||||
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
|
||||
/// this relay instead of the never-connected remainder.
|
||||
private func markEOSESubscribed(id: String, relayUrl: String) {
|
||||
guard var tracker = eoseTrackers[id],
|
||||
tracker.awaitingSend.remove(relayUrl) != nil else { return }
|
||||
tracker.awaitingEOSE.insert(relayUrl)
|
||||
tracker.didSend = true
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
|
||||
private func handleDisconnection(
|
||||
relayUrl: String,
|
||||
error: Error,
|
||||
connection: NostrRelayConnectionProtocol? = nil
|
||||
) {
|
||||
if let connection, connections[relayUrl] !== connection { return }
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
teardownRelayInboundPipeline(for: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||
state.awaitingRelays.contains(relayUrl) ? eventID : nil
|
||||
}
|
||||
for eventID in awaitingConfirmation {
|
||||
resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false)
|
||||
}
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
settleEOSETrackers(droppingRelay: relayUrl)
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
@@ -1506,7 +1201,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
teardownRelayInboundPipeline(for: normalizedRelayUrl)
|
||||
}
|
||||
|
||||
// Attempt immediate reconnection
|
||||
@@ -1601,77 +1295,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||
|
||||
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
|
||||
private struct InboundFrame: Sendable {
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
}
|
||||
|
||||
/// Lock-guarded registry of per-relay inbound streams.
|
||||
///
|
||||
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
|
||||
/// `Sendable` path to route a frame to the correct relay's stream without a
|
||||
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
|
||||
/// driven from the main actor; frame delivery (`yield`) can come from any
|
||||
/// thread. All access is serialized by a single lock — contention is negligible
|
||||
/// because the guarded critical section is only a dictionary lookup + yield.
|
||||
private final class InboundFrameRouter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
|
||||
private var tasks: [String: Task<Void, Never>] = [:]
|
||||
|
||||
/// Start a relay's stream + consumer if one does not already exist.
|
||||
/// Returns true when a new pipeline was created. The bounded
|
||||
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
|
||||
/// frames under a flood, never other relays' frames. Buffered memory per
|
||||
/// relay is bounded (not eliminated) at the frame cap times the per-frame
|
||||
/// byte cap (`maximumMessageSize`) — see TransportConfig.
|
||||
func startPipeline(
|
||||
for relayUrl: String,
|
||||
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
|
||||
) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if continuations[relayUrl] != nil { return false }
|
||||
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
|
||||
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
|
||||
)
|
||||
continuations[relayUrl] = continuation
|
||||
tasks[relayUrl] = makeConsumer(stream)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Route a frame to a relay's stream. No-op if the relay has no live
|
||||
/// pipeline (socket already torn down) — the frame is simply dropped, which
|
||||
/// is safe for best-effort Nostr inbound.
|
||||
func yield(_ frame: InboundFrame, to relayUrl: String) {
|
||||
lock.lock()
|
||||
let continuation = continuations[relayUrl]
|
||||
lock.unlock()
|
||||
continuation?.yield(frame)
|
||||
}
|
||||
|
||||
/// Finish a relay's stream. The consumer drains any already-buffered frames
|
||||
/// before exiting, so in-flight verified events are still delivered.
|
||||
func finishPipeline(for relayUrl: String) {
|
||||
lock.lock()
|
||||
let continuation = continuations.removeValue(forKey: relayUrl)
|
||||
tasks.removeValue(forKey: relayUrl)
|
||||
lock.unlock()
|
||||
continuation?.finish()
|
||||
}
|
||||
|
||||
func finishAll() {
|
||||
lock.lock()
|
||||
let allContinuations = continuations
|
||||
continuations.removeAll()
|
||||
tasks.removeAll()
|
||||
lock.unlock()
|
||||
for continuation in allContinuations.values {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ParsedInbound {
|
||||
case event(subId: String, event: NostrEvent)
|
||||
case ok(eventId: String, success: Bool, reason: String)
|
||||
@@ -1679,7 +1302,7 @@ private enum ParsedInbound {
|
||||
case notice(String)
|
||||
|
||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||
guard let data = message.dataWithinInboundLimit,
|
||||
guard let data = message.data,
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
array.count >= 2,
|
||||
let type = array[0] as? String else {
|
||||
@@ -1724,19 +1347,11 @@ private enum ParsedInbound {
|
||||
}
|
||||
|
||||
private extension URLSessionWebSocketTask.Message {
|
||||
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
||||
/// where we can (string length), and always before JSON parse.
|
||||
var dataWithinInboundLimit: Data? {
|
||||
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
||||
var data: Data? {
|
||||
switch self {
|
||||
case .string(let text):
|
||||
guard text.utf8.count <= maxBytes else { return nil }
|
||||
return text.data(using: .utf8)
|
||||
case .data(let data):
|
||||
guard data.count <= maxBytes else { return nil }
|
||||
return data
|
||||
@unknown default:
|
||||
return nil
|
||||
case .string(let text): text.data(using: .utf8)
|
||||
case .data(let data): data
|
||||
@unknown default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1848,29 +1463,6 @@ struct NostrFilter: Encodable {
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For the mesh bridge: rendezvous messages (kind 20000) and presence
|
||||
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
|
||||
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["r": cells]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For courier drops: sealed envelopes (kind 1401) parked under rotating
|
||||
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
|
||||
// UTC days x recipients) in one filter.
|
||||
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["x": recipientTagsHex]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
|
||||
@@ -39,4 +39,13 @@ enum NostrRelayURL {
|
||||
|
||||
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,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>3B52.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
if size > UInt64(limit) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
if proposedSize > limit {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
@@ -154,90 +158,3 @@ struct BitchatFilePacket {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-compatible identity for private media exchanged by clients using the
|
||||
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||
/// receivers derive the receipt key from fields already on the wire.
|
||||
///
|
||||
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||
/// burst ID. Including the normalized direction keeps a reused filename
|
||||
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||
enum PrivateMediaMessageIdentity {
|
||||
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||
private static let idPrefix = "media-"
|
||||
private static let digestHexLength = 32
|
||||
|
||||
static func isStableID(_ candidate: String) -> Bool {
|
||||
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||
let digest = candidate.dropFirst(idPrefix.count)
|
||||
guard digest.utf8.count == digestHexLength else { return false }
|
||||
return digest.utf8.allSatisfy { byte in
|
||||
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID,
|
||||
fileName: String?
|
||||
) -> String? {
|
||||
guard let fileName, !fileName.isEmpty else { return nil }
|
||||
let leafName = (fileName as NSString).lastPathComponent
|
||||
guard leafName == fileName else { return nil }
|
||||
|
||||
let path = leafName as NSString
|
||||
let stem = path.deletingPathExtension
|
||||
let fileExtension = path.pathExtension.lowercased()
|
||||
switch true {
|
||||
case stem.hasPrefix("img_"):
|
||||
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||
case stem.hasPrefix("voice_"):
|
||||
guard fileExtension == "m4a" else { return nil }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||
let voiceBurstID = stem.hasPrefix("voice_")
|
||||
? String(stem.dropFirst("voice_".count))
|
||||
: ""
|
||||
let hasBurstEntropy = voiceBurstID.count == 16
|
||||
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fields = [
|
||||
Data(senderPeerID.toShort().bare.utf8),
|
||||
Data(recipientPeerID.toShort().bare.utf8),
|
||||
Data(leafName.utf8)
|
||||
]
|
||||
var input = domain
|
||||
for field in fields {
|
||||
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||
var bigEndianLength = length.bigEndian
|
||||
withUnsafeBytes(of: &bigEndianLength) {
|
||||
input.append(contentsOf: $0)
|
||||
}
|
||||
input.append(field)
|
||||
}
|
||||
|
||||
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
for packet: BitchatFilePacket,
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID
|
||||
) -> String? {
|
||||
stableID(
|
||||
senderPeerID: senderPeerID,
|
||||
recipientPeerID: recipientPeerID,
|
||||
fileName: packet.fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,50 +74,27 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Private groups (0x04/0x05 reserved by other features)
|
||||
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
||||
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
||||
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
||||
// Private groups
|
||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Live voice (push-to-talk)
|
||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||
// Finalized private media. `0x20` is the value already deployed by the
|
||||
// Android client. The complete BitchatFilePacket is encrypted inside
|
||||
// Noise before the outer noiseEncrypted packet is fragmented.
|
||||
case privateFile = 0x20
|
||||
// Versioned peer state authenticated by the surrounding Noise session.
|
||||
// This is intentionally distinct from the public announce: announce
|
||||
// capabilities are discovery hints, while this payload proves possession
|
||||
// of the advertised Noise static key before downgrade state is pinned.
|
||||
case authenticatedPeerState = 0x21
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
// Transitive verification (web of trust)
|
||||
case vouch = 0x12 // Batch of vouch attestations
|
||||
|
||||
/// #1434 briefly used 0x09 before release. Accept it while prerelease
|
||||
/// builds age out, but never emit it. Decoders canonicalize both values to
|
||||
/// `.privateFile` so the compatibility alias cannot leak into app logic.
|
||||
static let prereleasePrivateFileRawValue: UInt8 = 0x09
|
||||
|
||||
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
|
||||
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
|
||||
}
|
||||
|
||||
static func isPrivateFile(rawValue: UInt8?) -> Bool {
|
||||
guard let rawValue else { return false }
|
||||
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .voiceFrame: return "voiceFrame"
|
||||
case .privateFile: return "privateFile"
|
||||
case .authenticatedPeerState: return "authenticatedPeerState"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
@@ -155,9 +132,6 @@ protocol BitchatDelegate: AnyObject {
|
||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
||||
|
||||
// Public live-voice burst packet (signature-verified by the transport)
|
||||
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
@@ -181,10 +155,6 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string at any channel precision (1-12 characters).
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||
guard (1...12).contains(geohash.count) else { return false }
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// MeshMessageIdentity.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Content-derived identity for public mesh messages.
|
||||
///
|
||||
/// The BLE wire carries no message ID for public broadcasts, so every device
|
||||
/// recomputes the same stable ID from the signed wire fields (sender ID,
|
||||
/// millisecond timestamp, content). That gives the mesh bridge a
|
||||
/// cross-device-consistent radio identity with zero wire change. Bridge events
|
||||
/// carry this value only as a hint for detecting a radio copy that is already
|
||||
/// present: sender/timestamp/content are public, so a different Nostr signer
|
||||
/// can copy them and must never be allowed to reserve the genuine event's
|
||||
/// authenticated dedup slot.
|
||||
enum MeshMessageIdentity {
|
||||
/// Matches the wire truncation in `BLEService.sendMessage`.
|
||||
static func millisecondTimestamp(_ date: Date) -> UInt64 {
|
||||
UInt64(date.timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String {
|
||||
let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed
|
||||
return String(Data(input.utf8).sha256Hex().prefix(32))
|
||||
}
|
||||
}
|
||||
@@ -32,14 +32,6 @@ struct NostrCarrierPacket: Equatable {
|
||||
enum Direction: UInt8 {
|
||||
case toGateway = 0x01
|
||||
case fromGateway = 0x02
|
||||
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
|
||||
/// publish its signed rendezvous event. Directed, like `toGateway`.
|
||||
case toBridge = 0x03
|
||||
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
|
||||
/// event from a remote island. Broadcast, like `fromGateway`.
|
||||
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
|
||||
/// carrier quietly — bridge traffic degrades to invisible, not junk.
|
||||
case fromBridge = 0x04
|
||||
}
|
||||
|
||||
let direction: Direction
|
||||
|
||||
@@ -9,25 +9,19 @@ struct AnnouncementPacket {
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
|
||||
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
|
||||
/// tagged rendezvous events without their own location fix.
|
||||
let bridgeGeohash: String?
|
||||
|
||||
init(
|
||||
nickname: String,
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data,
|
||||
directNeighbors: [Data]?,
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
capabilities: PeerCapabilities? = nil
|
||||
) {
|
||||
self.nickname = nickname
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.signingPublicKey = signingPublicKey
|
||||
self.directNeighbors = directNeighbors
|
||||
self.capabilities = capabilities
|
||||
self.bridgeGeohash = bridgeGeohash
|
||||
}
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
@@ -36,7 +30,6 @@ struct AnnouncementPacket {
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
case capabilities = 0x05
|
||||
case bridgeGeohash = 0x06
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -81,15 +74,6 @@ struct AnnouncementPacket {
|
||||
data.append(capabilityBytes)
|
||||
}
|
||||
|
||||
// TLV for bridge rendezvous cell (optional; old clients skip it)
|
||||
if let bridgeGeohash = bridgeGeohash,
|
||||
let cellData = bridgeGeohash.data(using: .utf8),
|
||||
!cellData.isEmpty, cellData.count <= 12 {
|
||||
data.append(TLVType.bridgeGeohash.rawValue)
|
||||
data.append(UInt8(cellData.count))
|
||||
data.append(cellData)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -100,7 +84,6 @@ struct AnnouncementPacket {
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
var capabilities: PeerCapabilities?
|
||||
var bridgeGeohash: String?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -133,10 +116,6 @@ struct AnnouncementPacket {
|
||||
}
|
||||
case .capabilities:
|
||||
capabilities = PeerCapabilities(encoded: Data(value))
|
||||
case .bridgeGeohash:
|
||||
if length <= 12 {
|
||||
bridgeGeohash = String(data: value, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -150,91 +129,7 @@ struct AnnouncementPacket {
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors,
|
||||
capabilities: capabilities,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// State that is authoritative only because it is carried inside an
|
||||
/// established Noise session. The public announce remains useful for
|
||||
/// discovery, but its self-signature cannot prove possession of the copied
|
||||
/// Noise public key it contains.
|
||||
///
|
||||
/// Wire format (v1):
|
||||
/// `[version=0x01][type][length][value]...`
|
||||
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
|
||||
/// - TLV `0x02`: 32-byte Ed25519 signing public key
|
||||
///
|
||||
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
|
||||
/// duplicates, non-canonical capability fields, and malformed lengths are
|
||||
/// rejected without changing authenticated state.
|
||||
struct AuthenticatedPeerStatePacket: Equatable {
|
||||
static let currentVersion: UInt8 = 1
|
||||
static let signingPublicKeyLength = 32
|
||||
|
||||
let capabilities: PeerCapabilities
|
||||
let signingPublicKey: Data
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case capabilities = 0x01
|
||||
case signingPublicKey = 0x02
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
|
||||
let capabilityBytes = capabilities.encoded()
|
||||
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
|
||||
|
||||
var data = Data([Self.currentVersion])
|
||||
data.append(TLVType.capabilities.rawValue)
|
||||
data.append(UInt8(capabilityBytes.count))
|
||||
data.append(capabilityBytes)
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
data.append(signingPublicKey)
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
|
||||
guard data.first == Self.currentVersion else { return nil }
|
||||
|
||||
var offset = 1
|
||||
var capabilities: PeerCapabilities?
|
||||
var signingPublicKey: Data?
|
||||
|
||||
while offset < data.count {
|
||||
guard offset + 2 <= data.count else { return nil }
|
||||
let typeRaw = data[offset]
|
||||
let length = Int(data[offset + 1])
|
||||
offset += 2
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = Data(data[offset..<(offset + length)])
|
||||
offset += length
|
||||
|
||||
guard let type = TLVType(rawValue: typeRaw) else {
|
||||
continue
|
||||
}
|
||||
switch type {
|
||||
case .capabilities:
|
||||
guard capabilities == nil,
|
||||
!value.isEmpty,
|
||||
value.count <= 8 else { return nil }
|
||||
let decoded = PeerCapabilities(encoded: value)
|
||||
guard decoded.encoded() == value else { return nil }
|
||||
capabilities = decoded
|
||||
|
||||
case .signingPublicKey:
|
||||
guard signingPublicKey == nil,
|
||||
value.count == Self.signingPublicKeyLength else { return nil }
|
||||
signingPublicKey = value
|
||||
}
|
||||
}
|
||||
|
||||
guard let capabilities, let signingPublicKey else { return nil }
|
||||
return AuthenticatedPeerStatePacket(
|
||||
capabilities: capabilities,
|
||||
signingPublicKey: signingPublicKey
|
||||
capabilities: capabilities
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [
|
||||
.vouch,
|
||||
.prekeys,
|
||||
.groups,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
static let localSupported: PeerCapabilities = {
|
||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||
return caps
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
//
|
||||
// VoiceBurstPacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Audio codec of a live voice burst. START packets carry it so receivers can
|
||||
/// reject bursts they can't decode instead of feeding garbage to the decoder.
|
||||
enum VoiceBurstCodec: UInt8 {
|
||||
/// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so
|
||||
/// the finalized `.m4a` and the live frames come from the same encoder
|
||||
/// settings.
|
||||
case aacLC16kMono = 0x01
|
||||
}
|
||||
|
||||
/// One packet of a live push-to-talk voice burst (the inner payload of
|
||||
/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload
|
||||
/// of `MessageType.voiceFrame`).
|
||||
///
|
||||
/// Wire format:
|
||||
/// ```
|
||||
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…]
|
||||
/// ```
|
||||
/// - flags 0x01 (START): payload = [codec: UInt8]
|
||||
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
|
||||
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
|
||||
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
|
||||
struct VoiceBurstPacket: Equatable {
|
||||
enum Kind: Equatable {
|
||||
case start(codec: VoiceBurstCodec)
|
||||
case frames([Data])
|
||||
case end(totalDataPackets: UInt16, durationMs: UInt32)
|
||||
case canceled
|
||||
}
|
||||
|
||||
static let burstIDSize = 8
|
||||
private static let headerSize = burstIDSize + 2 + 1
|
||||
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
|
||||
static let maxFramesPerPacket = 8
|
||||
|
||||
private enum Flags {
|
||||
static let start: UInt8 = 0x01
|
||||
static let end: UInt8 = 0x02
|
||||
static let canceled: UInt8 = 0x04
|
||||
}
|
||||
|
||||
let burstID: Data
|
||||
let seq: UInt16
|
||||
let kind: Kind
|
||||
|
||||
init?(burstID: Data, seq: UInt16, kind: Kind) {
|
||||
guard burstID.count == Self.burstIDSize else { return nil }
|
||||
if case .frames(let frames) = kind {
|
||||
guard !frames.isEmpty,
|
||||
frames.count <= Self.maxFramesPerPacket,
|
||||
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
|
||||
else { return nil }
|
||||
}
|
||||
self.burstID = burstID
|
||||
self.seq = seq
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var data = Data(capacity: Self.headerSize + payloadSize)
|
||||
data.append(burstID)
|
||||
data.append(UInt8((seq >> 8) & 0xFF))
|
||||
data.append(UInt8(seq & 0xFF))
|
||||
switch kind {
|
||||
case .start(let codec):
|
||||
data.append(Flags.start)
|
||||
data.append(codec.rawValue)
|
||||
case .frames(let frames):
|
||||
data.append(0)
|
||||
for frame in frames {
|
||||
let length = UInt16(frame.count)
|
||||
data.append(UInt8((length >> 8) & 0xFF))
|
||||
data.append(UInt8(length & 0xFF))
|
||||
data.append(frame)
|
||||
}
|
||||
case .end(let totalDataPackets, let durationMs):
|
||||
data.append(Flags.end)
|
||||
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
|
||||
data.append(UInt8(totalDataPackets & 0xFF))
|
||||
for shift in stride(from: 24, through: 0, by: -8) {
|
||||
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
|
||||
}
|
||||
case .canceled:
|
||||
data.append(Flags.canceled)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> VoiceBurstPacket? {
|
||||
// Work on a re-based copy so subscripting is offset-safe.
|
||||
let data = Data(data)
|
||||
guard data.count >= headerSize else { return nil }
|
||||
|
||||
let burstID = data.prefix(burstIDSize)
|
||||
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
|
||||
let flags = data[burstIDSize + 2]
|
||||
let payload = data.dropFirst(headerSize)
|
||||
|
||||
let kind: Kind
|
||||
switch flags {
|
||||
case Flags.start:
|
||||
guard let codecByte = payload.first,
|
||||
let codec = VoiceBurstCodec(rawValue: codecByte)
|
||||
else { return nil }
|
||||
kind = .start(codec: codec)
|
||||
case Flags.end:
|
||||
guard payload.count >= 6 else { return nil }
|
||||
let bytes = Array(payload)
|
||||
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
|
||||
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
kind = .end(totalDataPackets: total, durationMs: duration)
|
||||
case Flags.canceled:
|
||||
kind = .canceled
|
||||
case 0:
|
||||
var frames: [Data] = []
|
||||
var offset = payload.startIndex
|
||||
while offset < payload.endIndex {
|
||||
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
|
||||
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
|
||||
offset = payload.index(offset, offsetBy: 2)
|
||||
guard length > 0,
|
||||
payload.distance(from: offset, to: payload.endIndex) >= length,
|
||||
frames.count < maxFramesPerPacket
|
||||
else { return nil }
|
||||
let end = payload.index(offset, offsetBy: length)
|
||||
frames.append(Data(payload[offset..<end]))
|
||||
offset = end
|
||||
}
|
||||
guard !frames.isEmpty else { return nil }
|
||||
kind = .frames(frames)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
|
||||
}
|
||||
|
||||
static func makeBurstID() -> Data {
|
||||
var bytes = Data(count: burstIDSize)
|
||||
let result = bytes.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
|
||||
}
|
||||
guard result == errSecSuccess else {
|
||||
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private var payloadSize: Int {
|
||||
switch kind {
|
||||
case .start: return 1
|
||||
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
|
||||
case .end: return 6
|
||||
case .canceled: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy packetizer for outgoing bursts: batches encoded frames into
|
||||
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
|
||||
/// packet in a single BLE frame after Noise encryption and padding.
|
||||
/// Not thread-safe — confine to one queue.
|
||||
struct VoiceBurstPacketizer {
|
||||
let burstID: Data
|
||||
private let budget: Int
|
||||
private var pendingFrames: [Data] = []
|
||||
private var pendingSize = 0
|
||||
/// seq 0 is reserved for START; data packets start at 1.
|
||||
private(set) var nextSeq: UInt16 = 1
|
||||
private(set) var dataPacketCount: UInt16 = 0
|
||||
|
||||
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
|
||||
self.burstID = burstID
|
||||
self.budget = budget
|
||||
}
|
||||
|
||||
/// Adds one encoded frame, returning any packets that became full.
|
||||
/// Frames larger than the budget are dropped (the encoder's ~130-byte
|
||||
/// frames never hit this; it guards against misconfiguration looping).
|
||||
mutating func add(_ frame: Data) -> [Data] {
|
||||
let frameCost = 2 + frame.count
|
||||
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
|
||||
|
||||
var packets: [Data] = []
|
||||
if !pendingFrames.isEmpty,
|
||||
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|
||||
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
|
||||
packets.append(contentsOf: flush())
|
||||
}
|
||||
pendingFrames.append(frame)
|
||||
pendingSize += frameCost
|
||||
return packets
|
||||
}
|
||||
|
||||
/// Emits any buffered frames as a final data packet.
|
||||
mutating func flush() -> [Data] {
|
||||
guard !pendingFrames.isEmpty,
|
||||
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
|
||||
else {
|
||||
pendingFrames = []
|
||||
pendingSize = 0
|
||||
return []
|
||||
}
|
||||
pendingFrames = []
|
||||
pendingSize = 0
|
||||
nextSeq &+= 1
|
||||
dataPacketCount &+= 1
|
||||
return [packet.encode()]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ import Foundation
|
||||
/// Manages autocomplete functionality for chat
|
||||
final class AutocompleteService {
|
||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||
|
||||
private let commands = [
|
||||
"/msg", "/who", "/clear",
|
||||
"/hug", "/slap", "/fav", "/unfav",
|
||||
"/block", "/unblock"
|
||||
]
|
||||
|
||||
/// Get autocomplete suggestions for current text
|
||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||
@@ -66,6 +73,26 @@ final class AutocompleteService {
|
||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||
}
|
||||
|
||||
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
||||
guard let regex = commandRegex else { return nil }
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||
|
||||
guard let match = matches.last else { return nil }
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let suggestions = commands
|
||||
.filter { $0.hasPrefix("/\(prefix)") }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
|
||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||
}
|
||||
|
||||
private func needsArgument(command: String) -> Bool {
|
||||
switch command {
|
||||
case "/who", "/clear":
|
||||
|
||||
@@ -14,39 +14,22 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let messageTTL: UInt8
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Noise and signing public keys already recorded for the peer, if any
|
||||
/// (single registry read so both come from one consistent snapshot).
|
||||
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
|
||||
/// Signing key from the persisted cryptographic identity for the peer, if
|
||||
/// any. Registry pins do not survive app restarts or offline-peer
|
||||
/// eviction; this fallback keeps the TOFU signing-key pin effective for
|
||||
/// returning peers.
|
||||
let persistedSigningPublicKey: (PeerID) -> Data?
|
||||
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||
/// peer-state payload, if any (persistent identity-state read).
|
||||
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||
/// 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)
|
||||
/// Whether the link this packet arrived on is already bound to a
|
||||
/// different peer ID (ingress-registry + BLE-queue read). Directness
|
||||
/// rides on the unsigned TTL, so a replayed announce can look "direct"
|
||||
/// on the replayer's link; that link must not shortcut an absent peer
|
||||
/// into "connected".
|
||||
let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool
|
||||
/// Runs the registry mutation phase under the collections barrier.
|
||||
let withRegistryBarrier: (() -> Void) -> Void
|
||||
/// Upserts the verified announce into the peer registry.
|
||||
/// Returns `nil` when the registry refuses the announce because it carries
|
||||
/// a signing key different from the one already pinned for this peer.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let upsertVerifiedAnnounce: (
|
||||
_ peerID: PeerID,
|
||||
_ announcement: AnnouncementPacket,
|
||||
_ isConnected: Bool,
|
||||
_ now: Date
|
||||
) -> BLEPeerAnnounceUpdate?
|
||||
) -> BLEPeerAnnounceUpdate
|
||||
/// Debounced reconnect-log decision.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||
@@ -126,16 +109,7 @@ final class BLEAnnounceHandler {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
||||
if existingPeerKeys.signingPublicKey == nil {
|
||||
// The registry entry (and its signing-key pin) is dropped on app
|
||||
// restart and offline-peer eviction, but the persisted
|
||||
// cryptographic identity survives both. Fall back to it so a
|
||||
// returning peer is not treated as first contact — otherwise an
|
||||
// attacker could replay the peer's noiseKey/peerID with their own
|
||||
// signing key and re-pin the identity (TOFU downgrade).
|
||||
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
|
||||
}
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
@@ -149,49 +123,18 @@ final class BLEAnnounceHandler {
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||
announcement.noisePublicKey
|
||||
),
|
||||
announcedSigningPublicKey: announcement.signingPublicKey
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
if case .reject(.signingKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
|
||||
}
|
||||
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
var verifiedAnnounce = trustDecision.isVerified
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
let directLinkState = env.linkState(peerID)
|
||||
let isDirectAnnounce = packet.ttl == env.messageTTL
|
||||
// A "direct" announce arriving on a link that another peer already
|
||||
// owns is either a rotation heal or a replay with its TTL restored;
|
||||
// both are ambiguous, so only the rebind (which containment-checks
|
||||
// the claimed identity) may promote it — never this shortcut.
|
||||
//
|
||||
// Known limitation: denying the shortcut cannot prevent forged
|
||||
// presence outright. A rebind that passes the containment checks
|
||||
// promotes the claimed peer to connected — it must, or a legitimate
|
||||
// rotation on an open link would read as disconnected — so a replay
|
||||
// that wins the rebind (absent victim, cooldown clear) still forges
|
||||
// presence. That residue is presence display only: DMs stay gated on
|
||||
// canDeliverSecurely (no Noise session means retain + courier, see
|
||||
// MessageRouter.sendPrivate). What this check buys: the ambiguous
|
||||
// announce alone never flips presence — forging requires winning the
|
||||
// containment-checked rebind (never steals an identity that owns a
|
||||
// live link; at most one rebind per link per cooldown window).
|
||||
let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID)
|
||||
|
||||
env.withRegistryBarrier {
|
||||
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||
@@ -206,22 +149,12 @@ final class BLEAnnounceHandler {
|
||||
return
|
||||
}
|
||||
|
||||
// The registry re-checks the signing-key pin inside the barrier.
|
||||
// The pre-barrier trust check reads the registry outside the
|
||||
// barrier, so this closes the race where two announces for the
|
||||
// same peer are evaluated concurrently.
|
||||
guard let update = env.upsertVerifiedAnnounce(
|
||||
let update = env.upsertVerifiedAnnounce(
|
||||
peerID,
|
||||
announcement,
|
||||
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
|
||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
now
|
||||
) else {
|
||||
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
|
||||
verifiedAnnounce = false
|
||||
isNewPeer = false
|
||||
isReconnectedPeer = false
|
||||
return
|
||||
}
|
||||
)
|
||||
isNewPeer = update.isNewPeer
|
||||
isReconnectedPeer = update.wasDisconnected
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case signingKeyMismatch
|
||||
case authenticatedSigningKeyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
@@ -74,34 +72,12 @@ enum BLEAnnounceTrustPolicy {
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data,
|
||||
existingSigningPublicKey: Data? = nil,
|
||||
authenticatedSigningPublicKey: Data? = nil,
|
||||
announcedSigningPublicKey: Data
|
||||
announcedNoisePublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
// Strongest binding first: an Ed25519 key bound to this Noise identity
|
||||
// inside an authenticated Noise session can never be replaced by a
|
||||
// merely self-signed announce.
|
||||
if let authenticatedSigningPublicKey,
|
||||
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||
return .reject(.authenticatedSigningKeyMismatch)
|
||||
}
|
||||
|
||||
// TOFU signing-key pinning. The packet signature only proves the
|
||||
// announce is self-consistent — it is verified against the Ed25519 key
|
||||
// carried *inside the same announce*. Since peerIDs derive from the
|
||||
// broadcast (public) noise key, an attacker can replay a victim's
|
||||
// peerID+noiseKey with their own signing key and a valid
|
||||
// self-signature. Once we have bound a signing key to this peer,
|
||||
// refuse to silently replace it.
|
||||
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
||||
return .reject(.signingKeyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Thread-safe announce admission state.
|
||||
///
|
||||
/// Announce requests originate from the Bluetooth delegate queue, the
|
||||
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
|
||||
/// behind a lock makes admission and maintenance snapshots atomic when those
|
||||
/// request sources race.
|
||||
final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
struct BLEAnnounceThrottle {
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
@@ -23,18 +16,16 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||
now.timeIntervalSince(lastSent)
|
||||
}
|
||||
|
||||
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
lock.withLock {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,7 @@ enum BLEFanoutSelector {
|
||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||
peripheralPeerBindings: [String: PeerID] = [:],
|
||||
centralPeerBindings: [String: PeerID] = [:],
|
||||
preferredPeripheralPerPeer: [PeerID: String] = [:],
|
||||
collapseDuplicatePeerLinks: Bool = true,
|
||||
directedPeerHint: PeerID?,
|
||||
requireDirectPeerLink: Bool = false,
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
@@ -34,14 +31,10 @@ enum BLEFanoutSelector {
|
||||
to: directedPeerHint,
|
||||
links: rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings,
|
||||
preferredPeripheralPerPeer: preferredPeripheralPerPeer
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return directedSelection
|
||||
}
|
||||
if directedPeerHint != nil, requireDirectPeerLink {
|
||||
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||
}
|
||||
if let directedPeerHint,
|
||||
hasBoundLink(
|
||||
to: directedPeerHint,
|
||||
@@ -53,20 +46,11 @@ enum BLEFanoutSelector {
|
||||
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||
}
|
||||
|
||||
// Direct announces are the packet that binds a link to its peer
|
||||
// (BLEService's raw bind and verified rebind). Collapsing them per
|
||||
// peer starves duplicate same-peer links of the announce they need to
|
||||
// become bound — the duplicates then look "pre-announce" forever and
|
||||
// every broadcast sprays down all of them. Announces are small and
|
||||
// throttled, so they go on every live link.
|
||||
let allowed = collapseDuplicatePeerLinks
|
||||
? collapseDuplicateLinksPerPeer(
|
||||
rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings,
|
||||
preferredPeripheralPerPeer: preferredPeripheralPerPeer
|
||||
)
|
||||
: rawAllowed
|
||||
let allowed = collapseDuplicateLinksPerPeer(
|
||||
rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||
return BLEFanoutSelection(
|
||||
@@ -113,8 +97,7 @@ enum BLEFanoutSelector {
|
||||
to peerID: PeerID,
|
||||
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID],
|
||||
preferredPeripheralPerPeer: [PeerID: String]
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> BLEFanoutSelection? {
|
||||
let directLinks = collapseDuplicateLinksPerPeer(
|
||||
(
|
||||
@@ -122,8 +105,7 @@ enum BLEFanoutSelector {
|
||||
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||
),
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings,
|
||||
preferredPeripheralPerPeer: preferredPeripheralPerPeer
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||
@@ -158,8 +140,7 @@ enum BLEFanoutSelector {
|
||||
private static func collapseDuplicateLinksPerPeer(
|
||||
_ links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID],
|
||||
preferredPeripheralPerPeer: [PeerID: String]
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
|
||||
return links
|
||||
@@ -167,30 +148,13 @@ enum BLEFanoutSelector {
|
||||
|
||||
var seenPeers = Set<PeerID>()
|
||||
var keptPeripheralIDs: [String] = []
|
||||
// When a peer has several bound peripheral links (duplicate
|
||||
// connections after a restore), collapse onto its preferred one (the
|
||||
// most recently bound) instead of dictionary order — an arbitrary
|
||||
// pick could route a peer's single collapsed copy down a stale link.
|
||||
for id in links.peripheralIDs {
|
||||
guard let peer = peripheralPeerBindings[id],
|
||||
preferredPeripheralPerPeer[peer] == id,
|
||||
seenPeers.insert(peer).inserted else { continue }
|
||||
keptPeripheralIDs.append(id)
|
||||
}
|
||||
for id in links.peripheralIDs {
|
||||
if let peer = peripheralPeerBindings[id] {
|
||||
if preferredPeripheralPerPeer[peer] == id { continue }
|
||||
if !seenPeers.insert(peer).inserted { continue }
|
||||
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||
continue
|
||||
}
|
||||
keptPeripheralIDs.append(id)
|
||||
}
|
||||
|
||||
// Known limitation: centrals collapse in subscription order (oldest
|
||||
// first) — there is no recency signal like the peripheral reverse
|
||||
// map. A central-only peer with duplicate subscriptions rides the
|
||||
// oldest one until the remote side (which owns those connections)
|
||||
// consolidates on its next verified announce (bounded by its
|
||||
// retirement cooldown).
|
||||
var keptCentralIDs: [String] = []
|
||||
for id in links.centralIDs {
|
||||
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||
|
||||
@@ -14,10 +14,6 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let localNickname: () -> String
|
||||
/// Snapshot of known peers keyed by ID (registry read).
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Local signing key used to authenticate our own gossip-sync replays.
|
||||
let localSigningPublicKey: () -> Data
|
||||
/// 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.
|
||||
@@ -32,85 +28,10 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?
|
||||
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||
let privateMediaReceiptState: (
|
||||
_ messageID: String
|
||||
) -> BLEPrivateMediaReceiptState
|
||||
/// Atomically records a stable private-media ID after the payload save.
|
||||
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||
/// Releases the allocator's save-to-UI ownership guard after synchronous
|
||||
/// conversation insertion has completed.
|
||||
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
|
||||
/// Checks the authenticated sender before any private-media disk work.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Acknowledges stable private media only after its synchronous
|
||||
/// conversation delivery has completed.
|
||||
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||
/// Delivers `.messageReceived` as one main-actor hop while
|
||||
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||
/// The completion authorizes the stable-media ACK. Finalization runs after
|
||||
/// every delivery attempt, including rejection, so allocator ownership
|
||||
/// cannot leak indefinitely.
|
||||
let deliverMessage: (
|
||||
_ message: BitchatMessage,
|
||||
_ shouldDeliver: @escaping () -> Bool,
|
||||
_ completion: @escaping () -> Void,
|
||||
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||
) -> Void
|
||||
}
|
||||
|
||||
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||
///
|
||||
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||
/// if quota cleanup removed the file.
|
||||
private final class PrivateMediaArrivalDeduplicator {
|
||||
enum Reservation {
|
||||
case reserved
|
||||
case pending
|
||||
case accepted(URL)
|
||||
case tombstoned
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func reserve(
|
||||
_ messageID: String,
|
||||
receiptState: () -> BLEPrivateMediaReceiptState
|
||||
) -> Reservation {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending.contains(messageID) {
|
||||
return .pending
|
||||
}
|
||||
|
||||
switch receiptState() {
|
||||
case .accepted(let existingURL):
|
||||
return .accepted(existingURL)
|
||||
case .tombstoned:
|
||||
return .tombstoned
|
||||
case .unavailable:
|
||||
return .unavailable
|
||||
case .absent:
|
||||
break
|
||||
}
|
||||
|
||||
pending.insert(messageID)
|
||||
return .reserved
|
||||
}
|
||||
|
||||
func finish(_ messageID: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
pending.remove(messageID)
|
||||
}
|
||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||
let deliverMessage: (BitchatMessage) -> Void
|
||||
}
|
||||
|
||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||
@@ -118,204 +39,54 @@ private final class PrivateMediaArrivalDeduplicator {
|
||||
/// and UI delivery.
|
||||
final class BLEFileTransferHandler {
|
||||
private let environment: BLEFileTransferHandlerEnvironment
|
||||
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||
|
||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||
/// before the routing decision, so a forged directed packet cannot use a
|
||||
/// node that is not its recipient as an unsigned forwarding hop.
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling that was enforced against the accepted offer.
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||
let env = environment
|
||||
let localPeerID = env.localPeerID()
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
|
||||
guard let senderNickname = authenticatedRawSenderNickname(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peersSnapshot,
|
||||
env: env
|
||||
) else {
|
||||
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
|
||||
return false
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
|
||||
return true
|
||||
}
|
||||
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
_ = storeIncomingPayload(
|
||||
packet.payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
usesDurableReceipts: false,
|
||||
env: env
|
||||
)
|
||||
// Once authenticated, a local decode/quota/save failure is not proof
|
||||
// that downstream nodes should be denied the valid signed packet.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Accepts a file packet only after it has been authenticated and
|
||||
/// decrypted by the peer's Noise session. The inner packet deliberately
|
||||
/// has no redundant signature: Noise supplies sender authentication and
|
||||
/// confidentiality, while this handler retains the same validation,
|
||||
/// quota, persistence, and UI-delivery behavior as public files.
|
||||
@discardableResult
|
||||
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
|
||||
let env = environment
|
||||
let peers = env.peersSnapshot()
|
||||
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
|
||||
return storeIncomingPayload(
|
||||
payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||
// contract introduced with capability bit 8. Bit 9 advertises
|
||||
// sender-side automatic retry support; it must not downgrade
|
||||
// prior iOS clients to random IDs or single-check delivery.
|
||||
usesDurableReceipts: true,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingPayload(
|
||||
_ payload: Data,
|
||||
from peerID: PeerID,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
usesDurableReceipts: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return false
|
||||
return
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return false
|
||||
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 false
|
||||
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 false
|
||||
}
|
||||
|
||||
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||
SecureLogger.debug(
|
||||
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||
category: .security
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let messageID = usesDurableReceipts
|
||||
? PrivateMediaMessageIdentity.stableID(
|
||||
for: filePacket,
|
||||
senderPeerID: peerID,
|
||||
recipientPeerID: localPeerID
|
||||
)
|
||||
: nil
|
||||
if let messageID {
|
||||
switch privateMediaArrivals.reserve(
|
||||
messageID,
|
||||
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||
) {
|
||||
case .reserved:
|
||||
break
|
||||
case .pending:
|
||||
// The first arrival has not reached durable storage yet.
|
||||
// Coalesce this retry without ACKing so a failed first save
|
||||
// remains retryable by the sender.
|
||||
SecureLogger.debug(
|
||||
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .accepted(let existingFile):
|
||||
env.updatePeerLastSeen(peerID)
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
peerID: peerID,
|
||||
destination: existingFile,
|
||||
category: storedMediaCategory(
|
||||
for: existingFile,
|
||||
fallback: mime.category
|
||||
),
|
||||
env: env
|
||||
)
|
||||
SecureLogger.debug(
|
||||
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||
category: .session
|
||||
)
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: existingFile,
|
||||
env: env
|
||||
)
|
||||
return true
|
||||
case .tombstoned:
|
||||
// Explicit deletion is a durable terminal receiver decision.
|
||||
env.updatePeerLastSeen(peerID)
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
SecureLogger.debug(
|
||||
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .unavailable:
|
||||
// Never turn an unreadable ledger into an empty ledger. A
|
||||
// directory-level failure clears on retry; a quarantined
|
||||
// record keeps exactly this ID fail-closed while every other
|
||||
// payload still flows.
|
||||
SecureLogger.warning(
|
||||
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
defer {
|
||||
if let messageID {
|
||||
privateMediaArrivals.finish(messageID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
@@ -328,175 +99,27 @@ final class BLEFileTransferHandler {
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
if let messageID,
|
||||
!env.commitPrivateMediaFile(messageID, destination) {
|
||||
// A payload without its durable ID mapping cannot safely suppress
|
||||
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||
env.removeIncomingFile(destination)
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: isPrivate,
|
||||
peerID: peerID,
|
||||
destination: destination,
|
||||
category: mime.category,
|
||||
env: env
|
||||
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)
|
||||
|
||||
if let messageID {
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: destination,
|
||||
env: env
|
||||
)
|
||||
} else {
|
||||
env.deliverMessage(
|
||||
message,
|
||||
{ true },
|
||||
{},
|
||||
{ outcome in
|
||||
if outcome == .rejected {
|
||||
// Raw media has no durable receipt that can redeliver
|
||||
// it later. Do not leave a newly saved, UI-unowned file
|
||||
// available for a stale fallback path to misidentify.
|
||||
env.removeIncomingFile(destination)
|
||||
} else {
|
||||
// Plain delegates are invoked without synchronous
|
||||
// insertion confirmation. Preserve the payload for
|
||||
// that supported delivery path.
|
||||
env.finishIncomingFileDelivery(destination)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func deliverStableMessage(
|
||||
_ message: BitchatMessage,
|
||||
messageID: String,
|
||||
peerID: PeerID,
|
||||
expectedURL: URL,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) {
|
||||
env.deliverMessage(
|
||||
message,
|
||||
{
|
||||
guard case .accepted(let resolvedURL) =
|
||||
env.privateMediaReceiptState(messageID) else {
|
||||
return false
|
||||
}
|
||||
return resolvedURL.standardizedFileURL
|
||||
== expectedURL.standardizedFileURL
|
||||
},
|
||||
{
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
},
|
||||
{ _ in
|
||||
env.finishIncomingFileDelivery(expectedURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func incomingMessage(
|
||||
messageID: String?,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
peerID: PeerID,
|
||||
destination: URL,
|
||||
category: MimeType.Category,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: senderNickname,
|
||||
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which media views render
|
||||
// as an in-flight send.
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// The durable URL is authoritative during reconstruction. A sender that
|
||||
/// reuses a stable filename with a different MIME type must not change how
|
||||
/// the already-stored payload renders.
|
||||
private func storedMediaCategory(
|
||||
for url: URL,
|
||||
fallback: MimeType.Category
|
||||
) -> MimeType.Category {
|
||||
let mediaDirectory = url
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.lastPathComponent
|
||||
switch mediaDirectory {
|
||||
case MimeType.Category.audio.mediaDir:
|
||||
return .audio
|
||||
case MimeType.Category.image.mediaDir:
|
||||
return .image
|
||||
case MimeType.Category.file.mediaDir:
|
||||
return .file
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||
/// signing keys are preferred; persisted identities cover peers that have
|
||||
/// rotated or are not currently present in the registry.
|
||||
private func authenticatedRawSenderNickname(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
peers: [PeerID: BLEPeerInfo],
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> String? {
|
||||
guard packet.signature != nil else { return nil }
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let candidateKey = peerID == localPeerID
|
||||
? env.localSigningPublicKey()
|
||||
: peers[peerID]?.signingPublicKey
|
||||
let verifiedWithKnownKey = candidateKey.map {
|
||||
env.verifyPacketSignature(packet, $0)
|
||||
} ?? false
|
||||
let signedDisplayName = verifiedWithKnownKey
|
||||
? nil
|
||||
: env.signedSenderDisplayName(packet, peerID)
|
||||
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: localPeerID,
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
// The packet signature authenticates the announced peer; the old
|
||||
// connected-but-unsigned leniency is not involved.
|
||||
allowConnectedUnverified: true
|
||||
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
env.deliverMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling enforced against the accepted offer.
|
||||
static func validate(
|
||||
payload: Data,
|
||||
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
private struct Metadata {
|
||||
let type: UInt8
|
||||
let total: Int
|
||||
let timestamp: Date
|
||||
let isBroadcast: Bool
|
||||
@@ -149,6 +150,7 @@ struct BLEFragmentAssemblyBuffer {
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(
|
||||
type: header.originalType,
|
||||
total: header.total,
|
||||
timestamp: now,
|
||||
isBroadcast: header.isBroadcastFragment,
|
||||
@@ -201,11 +203,8 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue
|
||||
|| originalType == MessageType.noiseEncrypted.rawValue {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
// A large noiseEncrypted packet can be an E2E-encrypted private
|
||||
// file; its authenticated plaintext is validated after decrypt.
|
||||
return FileTransferLimits.maxFramedFileBytes
|
||||
}
|
||||
|
||||
|
||||
@@ -33,21 +33,13 @@ final class BLEFragmentHandler {
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
// Sync replay legitimately hands us our own fragments back (the RSR
|
||||
// ttl=0 restore path): after a relaunch the fragment store starts
|
||||
// empty, so our sync filter doesn't cover them and peers re-offer
|
||||
// them. Record them as seen — the next round's filter then covers
|
||||
// them and the redelivery stops — but skip assembly: we authored
|
||||
// the original, there is nothing to reassemble.
|
||||
// Don't process our own fragments
|
||||
if peerID == env.localPeerID() {
|
||||
if header.isBroadcastFragment {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
if header.isBroadcastFragment {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
@@ -2,271 +2,17 @@ import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct PanicRecoveryIntent {
|
||||
let fileMarkerEstablished: Bool
|
||||
let externalMarkerEstablished: Bool
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
|
||||
var hasDurableMarker: Bool {
|
||||
fileMarkerEstablished || externalMarkerEstablished
|
||||
}
|
||||
}
|
||||
|
||||
/// Small, dependency-injectable transaction surface used by ChatViewModel.
|
||||
/// Production persists the same intent in two independent locations before
|
||||
/// any application state is erased. Tests can inject an ephemeral operation
|
||||
/// set without touching the developer's Application Support directory.
|
||||
struct PanicRecoveryOperations {
|
||||
let isPending: () throws -> Bool
|
||||
let begin: () -> PanicRecoveryIntent
|
||||
let wipeMedia: (PanicRecoveryIntent) throws -> Void
|
||||
let complete: () throws -> Void
|
||||
|
||||
static func ephemeral(
|
||||
wipeMedia: @escaping () throws -> Void = {}
|
||||
) -> PanicRecoveryOperations {
|
||||
PanicRecoveryOperations(
|
||||
isPending: { false },
|
||||
begin: {
|
||||
PanicRecoveryIntent(
|
||||
fileMarkerEstablished: false,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in try wipeMedia() },
|
||||
complete: {}
|
||||
)
|
||||
}
|
||||
|
||||
static func live(
|
||||
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
defaults: UserDefaults = .standard
|
||||
) -> PanicRecoveryOperations {
|
||||
let defaultsKey = "bitchat.panicResetPending"
|
||||
return PanicRecoveryOperations(
|
||||
isPending: {
|
||||
if defaults.bool(forKey: defaultsKey) {
|
||||
return true
|
||||
}
|
||||
return try fileStore.isPanicRecoveryPending()
|
||||
},
|
||||
begin: {
|
||||
defaults.set(true, forKey: defaultsKey)
|
||||
let externalMarkerEstablished =
|
||||
defaults.synchronize()
|
||||
&& defaults.bool(forKey: defaultsKey)
|
||||
|
||||
let fileMarkerEstablished: Bool
|
||||
do {
|
||||
try fileStore.markPanicRecoveryPending()
|
||||
fileMarkerEstablished = true
|
||||
} catch {
|
||||
fileMarkerEstablished = false
|
||||
SecureLogger.error(
|
||||
"Failed to persist file panic-recovery marker: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: fileMarkerEstablished,
|
||||
externalMarkerEstablished: externalMarkerEstablished
|
||||
)
|
||||
},
|
||||
wipeMedia: { intent in
|
||||
try fileStore.panicWipe(
|
||||
hasDurablePendingMarker: intent.hasDurableMarker
|
||||
)
|
||||
},
|
||||
complete: {
|
||||
// Keep the independent defaults latch until the file marker
|
||||
// has definitely cleared. Any failure therefore remains
|
||||
// visible to the next launch.
|
||||
try fileStore.completePanicRecovery()
|
||||
defaults.removeObject(forKey: defaultsKey)
|
||||
guard defaults.synchronize(),
|
||||
!defaults.bool(forKey: defaultsKey) else {
|
||||
throw BLEIncomingFileStore.PanicRecoveryError
|
||||
.externalMarkerCommitFailed
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
enum PanicRecoveryError: Error {
|
||||
case externalMarkerCommitFailed
|
||||
case markerWriteFailed(Error)
|
||||
case markerWriteAndMediaWipeFailed(
|
||||
markerError: Error,
|
||||
mediaError: Error
|
||||
)
|
||||
}
|
||||
|
||||
struct PrivateMediaDeletionReservation: Sendable {
|
||||
fileprivate let id: UUID
|
||||
}
|
||||
|
||||
private final class PayloadCoordination: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var pendingDeliveryPaths: Set<String> = []
|
||||
var deletionReservations: [UUID: Set<String>] = [:]
|
||||
}
|
||||
|
||||
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
|
||||
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||
/// fail-closed startup decision before the full panic has committed.
|
||||
private static let panicRecoveryPendingMarkerFileName =
|
||||
".panic-recovery-pending"
|
||||
/// Compatibility with a short-lived development build that used the
|
||||
/// media-specific name for the same full-transaction latch.
|
||||
private static let legacyPanicRecoveryPendingMarkerFileName =
|
||||
".panic-media-wipe-pending"
|
||||
private static let mediaSubdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||
/// and kills playback — and the coordinator's startup sweep deletes any
|
||||
/// orphans a previous session left behind.
|
||||
static let liveCapturePrefix = "voice_live_"
|
||||
|
||||
/// Exposed so callers that write progressively into the store's
|
||||
/// directories (live voice captures) share the same file manager.
|
||||
let fileManager: FileManager
|
||||
private let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
private let quotaBytes: Int64
|
||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||
private let payloadCoordination: PayloadCoordination
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
baseDirectory: URL? = nil,
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||
try $0.write(to: $1, options: .atomic)
|
||||
},
|
||||
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||
) {
|
||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
self.quotaBytes = max(0, quotaBytes)
|
||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||
fileManager: fileManager,
|
||||
baseDirectory: baseDirectory,
|
||||
now: dateProvider
|
||||
)
|
||||
self.payloadCoordination = PayloadCoordination()
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
/// returning. Recreating the directory tree keeps later capture/receive
|
||||
/// paths usable without allowing a detached cleanup task to race them.
|
||||
///
|
||||
/// Marker persistence and deletion are deliberately separate error
|
||||
/// domains: even when both durable marker channels fail, deletion is
|
||||
/// still attempted before this method reports the marker failure.
|
||||
func panicWipe(
|
||||
hasDurablePendingMarker: Bool = false
|
||||
) throws {
|
||||
// The receipt index caches tombstones as well as accepted payloads,
|
||||
// while payload coordination retains save/delete reservations. Always
|
||||
// invalidate both on return, including partial-failure paths, so no
|
||||
// pre-panic receiver decision survives after identity reset.
|
||||
defer {
|
||||
privateMediaReceipts.resetForPanic()
|
||||
payloadCoordination.lock.lock()
|
||||
payloadCoordination.pendingDeliveryPaths.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.deletionReservations.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.lock.unlock()
|
||||
}
|
||||
|
||||
let markerError: Error?
|
||||
do {
|
||||
try markPanicRecoveryPending()
|
||||
markerError = nil
|
||||
} catch {
|
||||
markerError = error
|
||||
SecureLogger.error(
|
||||
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let filesDirectory = try rootDirectory()
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||
try fileManager.removeItem(at: filesDirectory)
|
||||
}
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
try fileManager.createDirectory(
|
||||
at: filesDirectory.appendingPathComponent(
|
||||
subdirectory,
|
||||
isDirectory: true
|
||||
),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if let markerError {
|
||||
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
|
||||
markerError: markerError,
|
||||
mediaError: error
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if let markerError, !hasDurablePendingMarker {
|
||||
throw PanicRecoveryError.markerWriteFailed(markerError)
|
||||
}
|
||||
}
|
||||
|
||||
func markPanicRecoveryPending() throws {
|
||||
let markerURL = try panicRecoveryPendingMarkerURL()
|
||||
try fileManager.createDirectory(
|
||||
at: markerURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
try panicMarkerWriter(Data([1]), markerURL)
|
||||
}
|
||||
|
||||
func isPanicRecoveryPending() throws -> Bool {
|
||||
try panicRecoveryMarkerURLs().contains {
|
||||
fileManager.fileExists(atPath: $0.path)
|
||||
}
|
||||
}
|
||||
|
||||
func completePanicRecovery() throws {
|
||||
for markerURL in try panicRecoveryMarkerURLs()
|
||||
where fileManager.fileExists(atPath: markerURL.path) {
|
||||
try fileManager.removeItem(at: markerURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves (and creates) an incoming-media directory for callers that
|
||||
/// write progressively instead of via `save` (live voice captures).
|
||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory
|
||||
}
|
||||
|
||||
func save(
|
||||
@@ -276,9 +22,6 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
fallbackExtension: String?,
|
||||
defaultPrefix: String
|
||||
) -> URL? {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
@@ -287,26 +30,8 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
fallbackExtension: fallbackExtension
|
||||
)
|
||||
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
|
||||
let deletionPaths = payloadCoordination
|
||||
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||
$0.formUnion($1)
|
||||
}
|
||||
let allocationReservations = deletionPaths.union(
|
||||
payloadCoordination.pendingDeliveryPaths
|
||||
)
|
||||
let destination = uniqueFileURL(
|
||||
in: base,
|
||||
fileName: sanitized,
|
||||
reservedPaths: (reservedPaths ?? []).union(
|
||||
allocationReservations
|
||||
),
|
||||
forceRandomizedName: reservedPaths == nil
|
||||
)
|
||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
payloadCoordination.pendingDeliveryPaths.insert(
|
||||
destination.standardizedFileURL.path
|
||||
)
|
||||
return destination
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||
@@ -314,199 +39,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops THIS instance's in-memory receipt index after a panic wipe.
|
||||
///
|
||||
/// `panicWipe` already resets the receipt store it runs on, but the
|
||||
/// production wipe runs on the `PanicRecoveryOperations.live()` file
|
||||
/// store while receipt lookups are served by `BLEService`'s own
|
||||
/// `incomingFileStore`. The service's panic path must invalidate its own
|
||||
/// cache explicitly or pre-panic decisions survive in memory.
|
||||
func resetPrivateMediaReceiptsForPanic() {
|
||||
privateMediaReceipts.resetForPanic()
|
||||
}
|
||||
|
||||
func privateMediaReceiptState(
|
||||
messageID: String
|
||||
) -> BLEPrivateMediaReceiptState {
|
||||
privateMediaReceipts.state(for: messageID)
|
||||
}
|
||||
|
||||
func commitPrivateMediaFile(
|
||||
messageID: String,
|
||||
storedURL: URL
|
||||
) -> Bool {
|
||||
privateMediaReceipts.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: storedURL
|
||||
)
|
||||
}
|
||||
|
||||
/// Reserves every receipt/UI path before the asynchronous deletion
|
||||
/// barrier. Allocation and reservation share one lock, so either an
|
||||
/// in-flight raw arrival is observed and deletion fails closed, or the
|
||||
/// arrival is forced onto a different filename.
|
||||
func reservePrivateMediaDeletion(
|
||||
messageIDs: [String],
|
||||
payloadRelativePaths: [String: String]
|
||||
) -> PrivateMediaDeletionReservation? {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
guard let paths = privateMediaReceipts
|
||||
.prospectiveDeletionPayloadPaths(
|
||||
messageIDs: messageIDs,
|
||||
payloadRelativePaths: payloadRelativePaths
|
||||
),
|
||||
paths.isDisjoint(
|
||||
with: payloadCoordination.pendingDeliveryPaths
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let reservation = PrivateMediaDeletionReservation(id: UUID())
|
||||
payloadCoordination.deletionReservations[reservation.id] = paths
|
||||
return reservation
|
||||
}
|
||||
|
||||
func commitPrivateMediaDeletion(
|
||||
reservation: PrivateMediaDeletionReservation,
|
||||
messageIDs: [String],
|
||||
payloadRelativePaths: [String: String],
|
||||
protectedPayloadRelativePaths: Set<String>
|
||||
) -> Bool {
|
||||
payloadCoordination.lock.lock()
|
||||
defer {
|
||||
payloadCoordination.deletionReservations.removeValue(
|
||||
forKey: reservation.id
|
||||
)
|
||||
payloadCoordination.lock.unlock()
|
||||
}
|
||||
guard payloadCoordination.deletionReservations[reservation.id] != nil
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return privateMediaReceipts.recordDeleted(
|
||||
messageIDs: messageIDs,
|
||||
payloadRelativePaths: payloadRelativePaths,
|
||||
protectedPayloadRelativePaths: protectedPayloadRelativePaths
|
||||
)
|
||||
}
|
||||
|
||||
/// Explicit deletion of a LEGACY (non-stable-ID) incoming payload.
|
||||
///
|
||||
/// Legacy media has no durable receipt, so the only safe unlink is one
|
||||
/// that can prove no other owner may hold the basename: the path must
|
||||
/// not be pending delivery, must not belong to an in-flight deletion
|
||||
/// reservation, and must not be owned by a stable receipt or journal
|
||||
/// entry. When any of those hold — or receipt state cannot be read —
|
||||
/// the file stays for bounded quota cleanup (the fail-safe fallback).
|
||||
/// Returns true only when the payload was verifiably unlinked.
|
||||
@discardableResult
|
||||
func removeLegacyIncomingFile(relativePath: String) -> Bool {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
guard let payload = incomingPayloadURL(
|
||||
relativePath: relativePath
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
let standardizedPath = payload.standardizedFileURL.path
|
||||
let reservedByDeletion = payloadCoordination.deletionReservations
|
||||
.values.contains { $0.contains(standardizedPath) }
|
||||
guard !reservedByDeletion,
|
||||
!payloadCoordination.pendingDeliveryPaths.contains(
|
||||
standardizedPath
|
||||
),
|
||||
let receiptOwnedPaths =
|
||||
privateMediaReceipts.reservedPayloadPaths(),
|
||||
!receiptOwnedPaths.contains(standardizedPath) else {
|
||||
return false
|
||||
}
|
||||
guard fileManager.fileExists(atPath: payload.path),
|
||||
(try? payload.resourceValues(
|
||||
forKeys: [.isRegularFileKey]
|
||||
).isRegularFile) == true else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: payload)
|
||||
return !fileManager.fileExists(atPath: payload.path)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to remove explicitly deleted legacy media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a `files/`-relative path iff it lands directly inside one of
|
||||
/// the incoming media directories. Anything else is not a deletable
|
||||
/// incoming payload.
|
||||
private func incomingPayloadURL(relativePath: String) -> URL? {
|
||||
guard !relativePath.isEmpty,
|
||||
let base = try? filesDirectory().standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
let candidate = base
|
||||
.appendingPathComponent(relativePath, isDirectory: false)
|
||||
.standardizedFileURL
|
||||
let parentPath = candidate.deletingLastPathComponent().path
|
||||
let incomingDirectories = [
|
||||
"voicenotes/incoming",
|
||||
"images/incoming",
|
||||
"files/incoming"
|
||||
]
|
||||
guard incomingDirectories.contains(where: { relativeDirectory in
|
||||
base.appendingPathComponent(
|
||||
relativeDirectory,
|
||||
isDirectory: true
|
||||
).standardizedFileURL.path == parentPath
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// Releases the short window between disk save and synchronous
|
||||
/// conversation insertion. Before this callback, a deletion transaction
|
||||
/// may not infer ownership from a stale bubble that names the same path.
|
||||
func finishIncomingFileDelivery(at storedURL: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||
func removeIncomingFile(at storedURL: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
)
|
||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: storedURL)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||
/// captures) are never evicted regardless of who triggers enforcement —
|
||||
/// a finalized transfer can arrive at quota while a burst is still
|
||||
/// streaming — but they still count toward usage.
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
@@ -532,26 +65,13 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
let needToFree = currentUsage - targetUsage
|
||||
let activeDeletionPaths = payloadCoordination
|
||||
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||
$0.formUnion($1)
|
||||
}
|
||||
let protectedPaths = activeDeletionPaths.union(
|
||||
payloadCoordination.pendingDeliveryPaths
|
||||
)
|
||||
var freedSpace: Int64 = 0
|
||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||
guard freedSpace < needToFree else { break }
|
||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
guard !protectedPaths.contains(
|
||||
file.url.standardizedFileURL.path
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
@@ -570,46 +90,15 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func rootDirectory() throws -> URL {
|
||||
try baseDirectory ?? fileManager.url(
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
}
|
||||
|
||||
private func panicRecoveryPendingMarkerURL() throws -> URL {
|
||||
try rootDirectory().appendingPathComponent(
|
||||
Self.panicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
)
|
||||
}
|
||||
|
||||
private func panicRecoveryMarkerURLs() throws -> [URL] {
|
||||
let root = try rootDirectory()
|
||||
return [
|
||||
root.appendingPathComponent(
|
||||
Self.panicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
),
|
||||
root.appendingPathComponent(
|
||||
Self.legacyPanicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||
return false
|
||||
}
|
||||
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||
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 {
|
||||
@@ -639,20 +128,11 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
return candidate.isEmpty ? defaultName : candidate
|
||||
}
|
||||
|
||||
private func uniqueFileURL(
|
||||
in directory: URL,
|
||||
fileName: String,
|
||||
reservedPaths: Set<String>,
|
||||
forceRandomizedName: Bool
|
||||
) -> URL {
|
||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||
let directoryPath = directory.standardizedFileURL.path
|
||||
func isInsideDirectory(_ url: URL) -> Bool {
|
||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||
}
|
||||
func isAvailable(_ url: URL) -> Bool {
|
||||
!reservedPaths.contains(url.standardizedFileURL.path)
|
||||
&& !fileManager.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
var candidate = directory.appendingPathComponent(fileName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
@@ -660,27 +140,19 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
if forceRandomizedName {
|
||||
let suffix = UUID().uuidString
|
||||
let randomizedName = ext.isEmpty
|
||||
? "\(baseName)_\(suffix)"
|
||||
: "\(baseName)_\(suffix).\(ext)"
|
||||
return directory.appendingPathComponent(randomizedName)
|
||||
}
|
||||
|
||||
if isAvailable(candidate) {
|
||||
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 isAvailable(candidate) {
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,21 +78,10 @@ struct BLEIngressLinkRegistry {
|
||||
return .failure(.selfLoopback(packetType: packet.type))
|
||||
}
|
||||
|
||||
if let boundPeerID, boundPeerID != claimedSenderID {
|
||||
if requiresDirectSenderBinding(packet) {
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
// A direct announce claiming a new sender on a bound link is either
|
||||
// a spoof or a legitimate peer-ID rotation on a connection that
|
||||
// outlived the old ID. Attribute it to the claimed sender and let
|
||||
// it through: announces are self-authenticating, and only a
|
||||
// signature-verified announce may rebind the link (BLEService).
|
||||
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||
return .success(BLEIngressPacketContext(
|
||||
receivedFromPeerID: claimedSenderID,
|
||||
validationPeerID: claimedSenderID
|
||||
))
|
||||
}
|
||||
if let boundPeerID,
|
||||
boundPeerID != claimedSenderID,
|
||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
|
||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||
@@ -109,15 +98,12 @@ struct BLEIngressLinkRegistry {
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
}
|
||||
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
||||
// must be the link peer — it elicits a full store replay, and the
|
||||
// response is addressed to whoever the sender claims to be.
|
||||
packet.type == MessageType.requestSync.rawValue
|
||||
}
|
||||
|
||||
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
if packet.type == MessageType.requestSync.rawValue { return true }
|
||||
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
}
|
||||
|
||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
|
||||
@@ -164,11 +164,7 @@ final class BLELinkStateStore {
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
// Scan all states rather than the 1:1 reverse map: after a state
|
||||
// restoration the same device can hold several live peripheral links
|
||||
// bound to one peer (it reappears under a fresh UUID while the
|
||||
// restored connection lives on).
|
||||
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
|
||||
if let peripheralUUID = peerToPeripheralUUID[peerID] {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||
@@ -177,13 +173,6 @@ final class BLELinkStateStore {
|
||||
return links
|
||||
}
|
||||
|
||||
/// The peer's most recently bound peripheral link, per peer. Used to keep
|
||||
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
|
||||
var preferredPeripheralBindings: [PeerID: String] {
|
||||
assertOwned()
|
||||
return peerToPeripheralUUID
|
||||
}
|
||||
|
||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]?.peerID
|
||||
@@ -214,37 +203,16 @@ final class BLELinkStateStore {
|
||||
|
||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
assertOwned()
|
||||
var previousPeerID: PeerID?
|
||||
let updated = updatePeripheral(peripheralUUID) {
|
||||
previousPeerID = $0.peerID
|
||||
$0.peerID = peerID
|
||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
guard updated != nil else { return }
|
||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
||||
// so the old peer no longer claims this link.
|
||||
if let previousPeerID, previousPeerID != peerID,
|
||||
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
||||
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
||||
}
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
|
||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||
// Only clear (or repair) the reverse map when it points at the removed
|
||||
// link: with duplicate links to one peer, removing a stale duplicate
|
||||
// must not strand the peer's surviving bound link.
|
||||
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
|
||||
// Prefer a writable survivor: repairing onto a link that is
|
||||
// mid-service-rediscovery would strand directed sends until the
|
||||
// characteristic comes back.
|
||||
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
|
||||
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
|
||||
peerToPeripheralUUID[peerID] = survivorUUID
|
||||
} else {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
}
|
||||
if let peerID {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
}
|
||||
return peerID
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||
let peerID: PeerID
|
||||
let peerIDData: Data
|
||||
let nickname: String
|
||||
}
|
||||
|
||||
/// Lock-backed local identity state shared by the transport's message,
|
||||
/// Bluetooth, maintenance, and main-actor entry points.
|
||||
///
|
||||
/// `peerID` and its binary wire representation must change as one unit during
|
||||
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||
/// view of the nickname and identity instead of reading three independently
|
||||
/// mutable properties across queues.
|
||||
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var state: BLELocalIdentitySnapshot
|
||||
|
||||
init(
|
||||
peerID: PeerID = PeerID(str: ""),
|
||||
nickname: String = "anon"
|
||||
) {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot() -> BLELocalIdentitySnapshot {
|
||||
lock.withLock { state }
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func replacePeerIdentity(with peerID: PeerID) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: state.nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||