Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021af3a22d | ||
|
|
8c9c1cc6ac | ||
|
|
55d51ba084 | ||
|
|
dc99d641c8 | ||
|
|
020de96519 | ||
|
|
d499c3e415 | ||
|
|
4baeaab717 | ||
|
|
74b8a47d02 | ||
|
|
ef848857b7 | ||
|
|
d5cf64f99e | ||
|
|
35fb9fdd42 | ||
|
|
3e9230229d | ||
|
|
78a291ab77 | ||
|
|
eacd8f0750 | ||
|
|
6886035632 | ||
|
|
e642f696cb | ||
|
|
3f677776bb | ||
|
|
5dffb04912 | ||
|
|
81c45e9649 | ||
|
|
963d3a089f | ||
|
|
f79c2e3e9f | ||
|
|
0c3136282e | ||
|
|
b043324035 | ||
|
|
bbe5e1ef4e | ||
|
|
28ffc53f3f | ||
|
|
8415c52913 | ||
|
|
a0c517c018 | ||
|
|
81a10f73f0 | ||
|
|
87910541ef | ||
|
|
f9032cf2b9 | ||
|
|
d4f0c49787 | ||
|
|
2360140760 | ||
|
|
70229f0be1 | ||
|
|
cee2bcd535 | ||
|
|
3c610a83cd | ||
|
|
60be88a4f5 | ||
|
|
ede6368296 | ||
|
|
276cde44e7 | ||
|
|
201dbac49a | ||
|
|
5fdc15d5af | ||
|
|
c2a5668569 | ||
|
|
7341696280 | ||
|
|
295f855b6f | ||
|
|
3ea4188699 | ||
|
|
a66c591f8e | ||
|
|
75da63c9d7 | ||
|
|
d285c6ad53 | ||
|
|
8296630cf3 | ||
|
|
c74e212ea3 | ||
|
|
7a0c821807 | ||
|
|
0d251ad20c | ||
|
|
c8ceac1968 | ||
|
|
9ccff9cce4 | ||
|
|
66536063ca | ||
|
|
e191e9c6f2 | ||
|
|
b31a63ce37 | ||
|
|
0f26a27980 | ||
|
|
68eeba97ff | ||
|
|
f688e529f6 | ||
|
|
96e32ba990 | ||
|
|
0a2f4d9c9d | ||
|
|
914135adb0 | ||
|
|
cd7ffa0df9 | ||
|
|
bbe1ed0652 | ||
|
|
f07b032b99 | ||
|
|
2cbcb290f7 | ||
|
|
9cf7c80518 | ||
|
|
f76fd8a538 | ||
|
|
09c2c12838 | ||
|
|
8378ff949a | ||
|
|
ca63893197 | ||
|
|
fdf28aa5bb | ||
|
|
266827ceff | ||
|
|
97bc3f53bc | ||
|
|
9dc0ba6991 | ||
|
|
af954b05ea | ||
|
|
c8381737bb | ||
|
|
fa136d8973 | ||
|
|
7fbe6a4a7e | ||
|
|
4791114406 | ||
|
|
1a6a08f92a | ||
|
|
0b007eee1a | ||
|
|
1480b51c76 | ||
|
|
0e38ccfb3b | ||
|
|
e74f36927f | ||
|
|
cf3b85f65c | ||
|
|
8899cb7f9e | ||
|
|
22be3d6392 | ||
|
|
8a867a17a1 | ||
|
|
ed86ed1065 | ||
|
|
e74d9a7937 | ||
|
|
8289a6d05e | ||
|
|
38331e62f1 | ||
|
|
7fb1f4a219 | ||
|
|
99d1d1dccd | ||
|
|
879d8cba12 | ||
|
|
ac3a2f2d34 | ||
|
|
45650854e7 | ||
|
|
75dd83d9cc | ||
|
|
93d01b8fa6 | ||
|
|
6c0dbbbd0d | ||
|
|
09087b74cc | ||
|
|
cc76086615 | ||
|
|
638f3f5005 | ||
|
|
6091ee83ad | ||
|
|
82736c4991 | ||
|
|
707b22878d | ||
|
|
80bed1f395 | ||
|
|
4b287f7490 | ||
|
|
3caf2d7663 | ||
|
|
14d025cc2a | ||
|
|
19b28cf49d | ||
|
|
a2825ca288 | ||
|
|
3a995e20b6 | ||
|
|
6bda919dd4 | ||
|
|
4093ee6733 | ||
|
|
eb2c128cab | ||
|
|
3eb4f2bd72 | ||
|
|
193cfdc06a | ||
|
|
ffa0d7aa4f | ||
|
|
9e84f5e822 | ||
|
|
df36b19afe | ||
|
|
ab0da61533 | ||
|
|
3be8fbf1c4 | ||
|
|
764f016d17 | ||
|
|
a6cb8872fb | ||
|
|
3daf02732a | ||
|
|
0d1cdc7644 | ||
|
|
43eef0d49c | ||
|
|
bafa461f26 | ||
|
|
602d2316ef | ||
|
|
c60eff2c11 | ||
|
|
4cfcefcda6 | ||
|
|
3e137d784c | ||
|
|
452e9e74fd | ||
|
|
3afd74ffc8 | ||
|
|
951e056a55 | ||
|
|
d265cfc765 | ||
|
|
473f5c7cce | ||
|
|
b5834cfc76 | ||
|
|
097d916da7 | ||
|
|
4b000b0785 | ||
|
|
19437a0bc6 | ||
|
|
34bae4a89d | ||
|
|
0331871980 | ||
|
|
f9f6ac92b8 | ||
|
|
ca374fd823 | ||
|
|
96f2986d65 | ||
|
|
b6e45e3228 | ||
|
|
c88ab3dd05 | ||
|
|
7d83310bc2 | ||
|
|
264a95b61a | ||
|
|
8562a76367 | ||
|
|
7e86d2061f | ||
|
|
a136b5b7e9 | ||
|
|
c043cf6354 | ||
|
|
72093f9648 | ||
|
|
187a5c3195 | ||
|
|
02b2a006c5 | ||
|
|
2403a6eb90 | ||
|
|
e47c5ae7b3 | ||
|
|
c1f5868d2d | ||
|
|
0515f4c6e8 | ||
|
|
8c7e3e7b9b | ||
|
|
90a5e8ba9d | ||
|
|
6206184862 | ||
|
|
74cf0d89cc | ||
|
|
b4b6aa5ca6 | ||
|
|
1e5a52f39f | ||
|
|
3fc64f6168 | ||
|
|
9964710de2 | ||
|
|
806c420313 | ||
|
|
194dedac43 | ||
|
|
9af46a9ff8 | ||
|
|
da3fcd5a21 | ||
|
|
b282536080 | ||
|
|
e156356c71 | ||
|
|
81a6e18d04 | ||
|
|
4fbbd24021 | ||
|
|
ec54877140 | ||
|
|
293d627c28 | ||
|
|
0c3f84224c | ||
|
|
7323c0b96c | ||
|
|
7bb835ffc9 | ||
|
|
23d63ab4df | ||
|
|
fa3c74f941 | ||
|
|
9bac52051a | ||
|
|
7b9ffe464a | ||
|
|
7b940485d9 | ||
|
|
5a87ee3e62 | ||
|
|
342eabbc00 | ||
|
|
241ce2d52c | ||
|
|
18b56e7393 | ||
|
|
beb04fc887 | ||
|
|
5fcffefa28 | ||
|
|
46ae039587 | ||
|
|
917f7ebe5f | ||
|
|
b47fb736f4 | ||
|
|
ebb5bb6558 | ||
|
|
7cfdcfe174 | ||
|
|
21e0cbc607 | ||
|
|
1b439a543e | ||
|
|
d30a3b14cf | ||
|
|
d03128612d | ||
|
|
ae294aab6d | ||
|
|
07b0e146f2 | ||
|
|
cc5939cb13 | ||
|
|
cada784844 | ||
|
|
b1aeb931bc | ||
|
|
b675738664 | ||
|
|
4091a30f10 | ||
|
|
238311aefb | ||
|
|
6defae71c6 | ||
|
|
b533d9560d | ||
|
|
f41a390a94 | ||
|
|
f83316bd1f | ||
|
|
09818a02ed | ||
|
|
9cd955ae2f | ||
|
|
49b1413d85 | ||
|
|
31be6b83a7 | ||
|
|
1563209797 | ||
|
|
99896bcded | ||
|
|
4b3077169a | ||
|
|
b84c36c6fa | ||
|
|
3eac5858e4 | ||
|
|
10b7c1fd80 | ||
|
|
bf3249aef7 | ||
|
|
95a6ec7315 | ||
|
|
74864472c7 | ||
|
|
9404c03477 | ||
|
|
6faa46a22f | ||
|
|
e02f4327c0 | ||
|
|
ce31d85323 | ||
|
|
b6d8a5b758 | ||
|
|
6630f5a792 | ||
|
|
0f5299a0f5 | ||
|
|
eb3bbfd861 | ||
|
|
a37243e780 | ||
|
|
90b134186b | ||
|
|
3c7e14f49d | ||
|
|
31275856dd | ||
|
|
b6cf44a824 | ||
|
|
b6ae08be60 | ||
|
|
d469704c34 | ||
|
|
c975abf2ff | ||
|
|
aff700a15e | ||
|
|
869d766f8d | ||
|
|
1b4f120014 |
@@ -0,0 +1,85 @@
|
|||||||
|
name: Arti Binary Provenance
|
||||||
|
|
||||||
|
# The Arti xcframework is a vendored binary; these checks turn the policy in
|
||||||
|
# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate:
|
||||||
|
# 1. The checked-in binary must match the hash manifest in the provenance doc.
|
||||||
|
# 2. A PR that changes the binary must also change at least one provenance
|
||||||
|
# input (Rust source, lockfile, build script, or the doc itself).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "localPackages/Arti/**"
|
||||||
|
- "docs/ARTI-BINARY-PROVENANCE.md"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "localPackages/Arti/**"
|
||||||
|
- "docs/ARTI-BINARY-PROVENANCE.md"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify-hashes:
|
||||||
|
name: Verify xcframework hashes against provenance doc
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Compare artifact hashes with manifest
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
doc="docs/ARTI-BINARY-PROVENANCE.md"
|
||||||
|
|
||||||
|
# Extract the manifest: lines of "<sha256> <path>" from the doc.
|
||||||
|
grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \
|
||||||
|
| sort -k2 > expected.txt
|
||||||
|
|
||||||
|
if [ ! -s expected.txt ]; then
|
||||||
|
echo "::error::No hash manifest found in $doc"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Hash the same file set the doc documents.
|
||||||
|
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \
|
||||||
|
| sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt
|
||||||
|
|
||||||
|
if ! diff -u expected.txt actual.txt; then
|
||||||
|
echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest."
|
||||||
|
|
||||||
|
require-provenance-evidence:
|
||||||
|
name: Binary changes must ship with provenance inputs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check changed files
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
base="origin/${{ github.base_ref }}"
|
||||||
|
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
|
||||||
|
changed=$(git diff --name-only "$base"...HEAD)
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$changed"
|
||||||
|
|
||||||
|
if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then
|
||||||
|
echo "No binary artifact changes; nothing to verify."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then
|
||||||
|
echo "Binary change is accompanied by provenance inputs."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")."
|
||||||
|
exit 1
|
||||||
@@ -25,47 +25,18 @@ jobs:
|
|||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Configure git
|
- name: Check for changes
|
||||||
|
id: git-check
|
||||||
run: |
|
run: |
|
||||||
git config user.email "action@github.com"
|
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
git config user.name "GitHub Action"
|
|
||||||
|
- name: Commit and push changes
|
||||||
- name: Create update branch if changes
|
if: steps.git-check.outputs.changes == 'true'
|
||||||
id: create_branch
|
|
||||||
run: |
|
run: |
|
||||||
# exit early if no changes
|
git config --local user.email "action@github.com"
|
||||||
if git diff --quiet --relays/online_relays_gps.csv; then
|
git config --local user.name "GitHub Action"
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# branch name with timestamp
|
|
||||||
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
git checkout -b "$BRANCH"
|
|
||||||
|
|
||||||
git add relays/online_relays_gps.csv
|
git add relays/online_relays_gps.csv
|
||||||
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
git commit -m "Automated update of relay data - $(date -u)"
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
git push
|
||||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Push branch
|
|
||||||
if: steps.create_branch.outputs.changed == 'true'
|
|
||||||
run: |
|
|
||||||
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
|
||||||
|
|
||||||
- name: Create pull request
|
|
||||||
if: steps.create_branch.outputs.changed == 'true'
|
|
||||||
uses: peter-evans/create-pull-request@v5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
commit-message: Automated update of relay data
|
|
||||||
branch: ${{ steps.create_branch.outputs.branch }}
|
|
||||||
base: main
|
|
||||||
title: Automated update of relay data
|
|
||||||
body: |
|
|
||||||
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
|
||||||
labels: automated, georelays
|
|
||||||
|
|
||||||
- name: No changes
|
|
||||||
if: steps.create_branch.outputs.changed != 'true'
|
|
||||||
run: echo "No changes to relays/online_relays_gps.csv"
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Dead Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
periphery:
|
||||||
|
name: Periphery scan
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||||
|
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Periphery
|
||||||
|
# homebrew-core formula; the peripheryapp tap lags years behind.
|
||||||
|
run: brew install periphery
|
||||||
|
|
||||||
|
- name: Scan for dead code
|
||||||
|
# Config comes from .periphery.yml; known findings (mostly iOS-only
|
||||||
|
# code invisible to a macOS scan) are suppressed by the committed
|
||||||
|
# baseline. --strict fails the step when NEW dead code appears.
|
||||||
|
run: periphery scan --strict --disable-update-check
|
||||||
@@ -5,23 +5,175 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Swift Tests
|
name: Run Swift Tests (${{ matrix.name }})
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||||
|
# default (observed: intermittent app-suite hangs starving the queue).
|
||||||
|
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||||
|
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||||
|
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: app
|
||||||
|
path: .
|
||||||
|
- name: BitLogger
|
||||||
|
path: localPackages/BitLogger
|
||||||
|
- name: BitFoundation
|
||||||
|
path: localPackages/BitFoundation
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Swift
|
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
|
||||||
uses: swift-actions/setup-swift@v2
|
# the runner image. A standalone swift.org toolchain (setup-swift) broke
|
||||||
|
# whenever the image's Xcode moved ahead of it ("this SDK is not
|
||||||
|
# supported by the compiler").
|
||||||
|
- name: Note toolchain version (cache key)
|
||||||
|
id: swift-version
|
||||||
|
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build the package
|
- name: Cache build artifacts
|
||||||
run: swift build
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ matrix.path }}/.build
|
||||||
|
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
|
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
|
||||||
|
|
||||||
|
- name: Build tests
|
||||||
|
# Built separately so the hang watchdog below times only test
|
||||||
|
# execution: a cold-cache coverage build on a slow runner can
|
||||||
|
# legitimately take several minutes, and is already bounded by the
|
||||||
|
# 15-minute job timeout.
|
||||||
|
run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: swift test --parallel
|
# Perf benchmarks are excluded here and run in their own serial step
|
||||||
|
# below: measuring while parallel test processes contend for cores
|
||||||
|
# produces noisy numbers, and the XCTest measure machinery has hung
|
||||||
|
# intermittently under parallel workers on loaded runners. Excluded
|
||||||
|
# via --skip (not just the env guard): every app run since the
|
||||||
|
# baselines landed timed out at the 15-minute job limit with the
|
||||||
|
# baseline tests dispatched into the parallel phase.
|
||||||
|
#
|
||||||
|
# The watchdog samples any still-running test processes after 5
|
||||||
|
# minutes (the suite passes in seconds when healthy; the build is
|
||||||
|
# done by this step) and kills the run, so a hang fails fast with
|
||||||
|
# stacks in the log instead of a silent timeout.
|
||||||
|
env:
|
||||||
|
BITCHAT_SKIP_PERF_BASELINES: "1"
|
||||||
|
run: |
|
||||||
|
swift test --skip-build --parallel --quiet --enable-code-coverage \
|
||||||
|
--skip PerformanceBaselineTests \
|
||||||
|
--package-path ${{ matrix.path }} &
|
||||||
|
test_pid=$!
|
||||||
|
(
|
||||||
|
sleep 300
|
||||||
|
if kill -0 "$test_pid" 2>/dev/null; then
|
||||||
|
echo "::group::Tests still running after 5 minutes — sampling before kill"
|
||||||
|
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
|
||||||
|
echo "--- sample of pid $pid ---"
|
||||||
|
sample "$pid" 5 2>/dev/null || true
|
||||||
|
done
|
||||||
|
echo "::endgroup::"
|
||||||
|
pkill -KILL -P "$test_pid" 2>/dev/null || true
|
||||||
|
kill -KILL "$test_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
) &
|
||||||
|
watchdog_pid=$!
|
||||||
|
wait "$test_pid" && status=0 || status=$?
|
||||||
|
kill "$watchdog_pid" 2>/dev/null || true
|
||||||
|
exit "$status"
|
||||||
|
|
||||||
|
# Benchmarks run serially on an otherwise idle runner for stable
|
||||||
|
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||||
|
- name: Run performance benchmarks (serial)
|
||||||
|
if: matrix.name == 'app'
|
||||||
|
timeout-minutes: 6
|
||||||
|
env:
|
||||||
|
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
|
||||||
|
run: swift test --quiet --filter PerformanceBaselineTests
|
||||||
|
|
||||||
|
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||||
|
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||||
|
# catches algorithmic regressions, never runner variance. If a metric
|
||||||
|
# still lands below floor (a saturated runner can dip one), the script
|
||||||
|
# re-runs the benchmarks — appending to the same log and keeping each
|
||||||
|
# benchmark's best value per metric — so noise clears on retry while a
|
||||||
|
# real regression fails every attempt. Floors are never lowered by this.
|
||||||
|
- name: Performance floor gate
|
||||||
|
if: matrix.name == 'app'
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||||
|
|
||||||
|
# Informational only: surfaces per-file and total line coverage in the
|
||||||
|
# job log so coverage trends are visible on every PR. No thresholds —
|
||||||
|
# this must never be the reason a build goes red.
|
||||||
|
- name: Coverage summary
|
||||||
|
run: |
|
||||||
|
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||||
|
PROF="$BIN_PATH/codecov/default.profdata"
|
||||||
|
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||||
|
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||||
|
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||||
|
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||||
|
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||||
|
else
|
||||||
|
echo "No coverage data found; skipping summary."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SPM tests above only compile the macOS slice; this job covers the
|
||||||
|
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
||||||
|
ios-build:
|
||||||
|
name: Build iOS app (simulator)
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Build iOS (simulator, no signing)
|
||||||
|
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild -project bitchat.xcodeproj \
|
||||||
|
-scheme "bitchat (iOS)" \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
build
|
||||||
|
|
||||||
|
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||||
|
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||||
|
# it can never break the documented xcodebuild path or block a merge.
|
||||||
|
lint:
|
||||||
|
name: SwiftLint (advisory)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
# This job runs a third-party container image, so give it the least
|
||||||
|
# privilege we can: a read-only token, and no credentials left in the
|
||||||
|
# checkout for the container to find.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
container:
|
||||||
|
# Tag for readability, digest for immutability (tags can be repointed).
|
||||||
|
# Bump both together, deliberately — never a floating tag.
|
||||||
|
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run SwiftLint
|
||||||
|
run: swiftlint lint --reporter github-actions-logging
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ plans/
|
|||||||
## AI
|
## AI
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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
|
||||||
|
relative_results: true
|
||||||
|
baseline: .periphery.baseline.json
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||||
|
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||||
|
excluded:
|
||||||
|
- .build
|
||||||
|
- .swiftpm
|
||||||
|
- .DerivedData
|
||||||
|
- DerivedData
|
||||||
|
- build
|
||||||
|
- localPackages/*/.build
|
||||||
|
|
||||||
|
disabled_rules:
|
||||||
|
- line_length
|
||||||
|
- type_name
|
||||||
|
- identifier_name
|
||||||
|
- statement_position
|
||||||
|
- implicit_optional_initialization
|
||||||
|
- force_try
|
||||||
|
- vertical_whitespace
|
||||||
|
- for_where
|
||||||
|
- control_statement
|
||||||
|
- void_function_in_ternary
|
||||||
|
- redundant_discardable_let # SwiftUI breaks without it
|
||||||
|
# To be enabled as we fix the issues
|
||||||
|
- trailing_whitespace
|
||||||
|
- cyclomatic_complexity
|
||||||
|
- function_body_length
|
||||||
|
- function_parameter_count
|
||||||
|
- type_body_length
|
||||||
|
- file_length
|
||||||
|
- large_tuple
|
||||||
|
- force_cast
|
||||||
|
- multiple_closures_with_trailing_closure
|
||||||
|
- nesting
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.0
|
MARKETING_VERSION = 1.7.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
|
|||||||
CODE_SIGN_STYLE = Automatic
|
CODE_SIGN_STYLE = Automatic
|
||||||
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||||
|
APP_GROUP_ID = group.chat.bitchat
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# bitchat Privacy Policy
|
# bitchat Privacy Policy
|
||||||
|
|
||||||
*Last updated: January 2025*
|
*Last updated: June 2026*
|
||||||
|
|
||||||
## Our Commitment
|
## Our Commitment
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||||
- **No servers** - Everything happens on your device and through peer-to-peer connections
|
- **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
|
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||||
- **Open source** - You can verify these claims by reading our code
|
- **Open source** - You can verify these claims by reading our code
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
|
|
||||||
### On Your Device Only
|
### On Your Device Only
|
||||||
|
|
||||||
1. **Identity Key**
|
1. **Identity Keys**
|
||||||
- A cryptographic key generated on first launch
|
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
||||||
- Stored locally in your device's secure storage
|
- Stored locally in your device's secure storage
|
||||||
- Allows you to maintain "favorite" relationships across app restarts
|
- Allows you to maintain "favorite" relationships across app restarts
|
||||||
- Never leaves your device
|
- Private keys never leave your device; public keys are shared when needed for messaging
|
||||||
|
|
||||||
2. **Nickname**
|
2. **Nickname**
|
||||||
- The display name you choose (or auto-generated)
|
- The display name you choose (or auto-generated)
|
||||||
@@ -38,12 +38,19 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
- Stored only on your device
|
- Stored only on your device
|
||||||
- Allows you to recognize these peers in future sessions
|
- Allows you to recognize these peers in future sessions
|
||||||
|
|
||||||
|
5. **Optional Location Channel State**
|
||||||
|
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
|
||||||
|
- Stored locally on your device so the location-channel UI can restore your choices
|
||||||
|
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
|
||||||
|
- Exact latitude and longitude are not persisted by bitchat
|
||||||
|
|
||||||
### Temporary Session Data
|
### Temporary Session Data
|
||||||
|
|
||||||
During each session, bitchat temporarily maintains:
|
During each session, bitchat temporarily maintains:
|
||||||
- Active peer connections (forgotten when app closes)
|
- Active peer connections (forgotten when app closes)
|
||||||
- Routing information for message delivery
|
- Routing information for message delivery
|
||||||
- Cached messages for offline peers (12 hours max)
|
- Cached messages for offline peers (12 hours max)
|
||||||
|
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
|
||||||
|
|
||||||
## What Information is Shared
|
## What Information is Shared
|
||||||
|
|
||||||
@@ -62,13 +69,21 @@ When you join a password-protected room:
|
|||||||
- Your nickname appears in the member list
|
- Your nickname appears in the member list
|
||||||
- Room owners can see you've joined
|
- Room owners can see you've joined
|
||||||
|
|
||||||
|
### With Nostr Relays (Optional Features)
|
||||||
|
|
||||||
|
If you enable Nostr-backed features:
|
||||||
|
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
|
||||||
|
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
|
||||||
|
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
|
||||||
|
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
|
||||||
|
|
||||||
## What We DON'T Do
|
## What We DON'T Do
|
||||||
|
|
||||||
bitchat **never**:
|
bitchat **never**:
|
||||||
- Collects personal information
|
- Collects personal information
|
||||||
- Tracks your location
|
- Sells or shares your exact GPS location
|
||||||
- Stores data on servers
|
- Stores data on servers we operate
|
||||||
- Shares data with third parties
|
- Sells your data to advertisers or data brokers
|
||||||
- Uses analytics or telemetry
|
- Uses analytics or telemetry
|
||||||
- Creates user profiles
|
- Creates user profiles
|
||||||
- Requires registration
|
- Requires registration
|
||||||
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
|
|||||||
## Your Rights
|
## Your Rights
|
||||||
|
|
||||||
You have complete control:
|
You have complete control:
|
||||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
|
||||||
- **Leave Anytime**: Close the app and your presence disappears
|
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
|
||||||
- **No Account**: Nothing to delete from servers because there are none
|
- **No Account**: No account record exists for you to delete from us
|
||||||
- **Portability**: Your data never leaves your device unless you export it
|
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
|
||||||
|
|
||||||
## Bluetooth & Permissions
|
## Bluetooth & Permissions
|
||||||
|
|
||||||
bitchat requires Bluetooth permission to function:
|
bitchat requires Bluetooth permission to function:
|
||||||
- Used only for peer-to-peer communication
|
- Used only for peer-to-peer communication
|
||||||
- No location data is accessed or stored
|
|
||||||
- Bluetooth is not used for tracking
|
- Bluetooth is not used for tracking
|
||||||
- You can revoke this permission at any time in system settings
|
- You can revoke this permission at any time in system settings
|
||||||
|
|
||||||
|
## Location Permission
|
||||||
|
|
||||||
|
Location permission is optional and is used only for location channels:
|
||||||
|
- Used to compute local geohash channels and display names
|
||||||
|
- Requested as when-in-use permission
|
||||||
|
- Exact coordinates are not shared in messages or stored by bitchat
|
||||||
|
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
|
||||||
|
- You can revoke this permission at any time in system settings
|
||||||
|
|
||||||
## Children's Privacy
|
## Children's Privacy
|
||||||
|
|
||||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||||
@@ -106,12 +129,15 @@ bitchat does not knowingly collect information from children. The app has no age
|
|||||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||||
- **Identity Key**: Persists until you delete the app
|
- **Identity Key**: Persists until you delete the app
|
||||||
- **Favorites**: Persist until you remove them or delete the app
|
- **Favorites**: Persist until you remove them or delete the app
|
||||||
|
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
|
||||||
|
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
|
||||||
- **Everything Else**: Exists only during active sessions
|
- **Everything Else**: Exists only during active sessions
|
||||||
|
|
||||||
## Security Measures
|
## Security Measures
|
||||||
|
|
||||||
- All communication is encrypted
|
- All communication is encrypted
|
||||||
- No data transmitted to servers (there are none)
|
- 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
|
- Open source code for public audit
|
||||||
- Regular security updates
|
- Regular security updates
|
||||||
- Cryptographic signatures prevent tampering
|
- Cryptographic signatures prevent tampering
|
||||||
@@ -121,7 +147,7 @@ bitchat does not knowingly collect information from children. The app has no age
|
|||||||
If we update this policy:
|
If we update this policy:
|
||||||
- The "Last updated" date will change
|
- The "Last updated" date will change
|
||||||
- The updated policy will be included in the app
|
- The updated policy will be included in the app
|
||||||
- No retroactive changes can affect data (since we don't collect any)
|
- No retroactive changes can make us collect data already held only in your app
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
@@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions:
|
|||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
|
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ let package = Package(
|
|||||||
.executable(
|
.executable(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
targets: ["bitchat"]
|
targets: ["bitchat"]
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies: [
|
||||||
.package(path: "localPackages/Tor"),
|
.package(path: "localPackages/Arti"),
|
||||||
|
.package(path: "localPackages/BitFoundation"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -25,13 +26,15 @@ let package = Package(
|
|||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.product(name: "Tor", package: "Tor")
|
.product(name: "Tor", package: "Arti")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
|
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard",
|
||||||
@@ -43,15 +46,24 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: [
|
||||||
|
"bitchat",
|
||||||
|
.product(name: "BitFoundation", package: "BitFoundation")
|
||||||
|
],
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md",
|
||||||
|
// CI perf gate data (read by scripts/check-perf-floors.sh),
|
||||||
|
// not a test resource.
|
||||||
|
"Performance/perf-floors.json"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization"),
|
||||||
.process("Noise")
|
// Only the vector fixture: declaring the whole "Noise"
|
||||||
|
// directory would claim its .swift test files as resources
|
||||||
|
// and silently drop them from compilation.
|
||||||
|
.process("Noise/NoiseTestVectors.json")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
|
|
||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
@@ -1,309 +1,141 @@
|
|||||||
# BitChat Protocol Whitepaper
|
# bitchat Protocol Whitepaper
|
||||||
|
|
||||||
**Version 1.1**
|
**Version 2.0**
|
||||||
|
|
||||||
**Date: July 25, 2025**
|
**Date: July 6, 2026**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abstract
|
## Abstract
|
||||||
|
|
||||||
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
|
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Introduction
|
## 1. Design Goals
|
||||||
|
|
||||||
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
|
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
||||||
|
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||||
|
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||||
|
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||||
|
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||||
|
|
||||||
The design goals of the BitChat Protocol are:
|
## 2. Architecture Overview
|
||||||
|
|
||||||
* **Confidentiality:** All communication must be unreadable to third parties.
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
|
||||||
* **Integrity:** Messages cannot be tampered with in transit.
|
|
||||||
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
|
||||||
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
|
||||||
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
|
||||||
|
|
||||||
This paper specifies the technical details of the protocol designed to meet these goals.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
|
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||||
|
|
||||||
---
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
## 2. Protocol Stack
|
## 3. Identity
|
||||||
|
|
||||||
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
Each device holds two long-term key pairs in the Keychain:
|
||||||
|
|
||||||
```mermaid
|
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||||
graph TD
|
* an **Ed25519 signing key** for packet signatures.
|
||||||
A[Application Layer] --> B[Session Layer];
|
|
||||||
B --> C[Encryption Layer];
|
|
||||||
C --> D[Transport Layer];
|
|
||||||
|
|
||||||
subgraph "BitChat Application"
|
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||||
A
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Message Framing & State"
|
## 4. BLE Mesh Layer
|
||||||
B
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Noise Protocol Framework"
|
### 4.1 Packet Format
|
||||||
C
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "BLE, Wi-Fi Direct, etc."
|
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||||
D
|
|
||||||
end
|
|
||||||
|
|
||||||
style A fill:#cde4ff
|
### 4.2 Flood Control
|
||||||
style B fill:#b5d8ff
|
|
||||||
style C fill:#9ac2ff
|
|
||||||
style D fill:#7eadff
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
Relaying is a deterministic controlled flood tuned by local connection degree:
|
||||||
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
|
||||||
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
|
||||||
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
|
||||||
|
|
||||||
---
|
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
|
||||||
|
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
|
||||||
|
* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||||
|
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||||
|
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||||
|
|
||||||
## 3. Identity and Key Management
|
### 4.3 Routing
|
||||||
|
|
||||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||||
|
|
||||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
### 4.4 Fragmentation
|
||||||
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
|
||||||
|
|
||||||
### 3.1. Fingerprint
|
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
|
||||||
|
|
||||||
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
|
### 4.5 Presence
|
||||||
|
|
||||||
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||||
|
|
||||||
### 3.2. Identity Management
|
## 5. Encryption
|
||||||
|
|
||||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
### 5.1 Live Sessions: Noise XX
|
||||||
|
|
||||||
---
|
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||||
|
|
||||||
## 4. The Social Trust Layer
|
### 5.2 Offline Seals: Noise X
|
||||||
|
|
||||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||||
|
|
||||||
### 4.1. Peer Verification
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||||
|
|
||||||
### 4.2. Favorites and Blocking
|
## 6. Store and Forward
|
||||||
|
|
||||||
To improve the user experience and provide control over interactions, the protocol supports:
|
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
||||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
|
||||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
|
||||||
|
|
||||||
---
|
### 6.1 Sender Outbox
|
||||||
|
|
||||||
## 5. The Noise Protocol Layer
|
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
|
||||||
|
|
||||||
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
### 6.2 Couriers
|
||||||
|
|
||||||
### 5.1. Protocol Name
|
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
|
||||||
|
|
||||||
The specific Noise protocol implemented is:
|
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
|
||||||
|
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
|
||||||
|
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
|
||||||
|
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
|
||||||
|
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
|
||||||
|
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
|
||||||
|
|
||||||
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
### 6.3 Public History (Gossip Sync)
|
||||||
|
|
||||||
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
|
||||||
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
|
||||||
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
|
||||||
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
|
||||||
|
|
||||||
### 5.2. The `XX` Handshake
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||||
|
|
||||||
```mermaid
|
### 6.5 Delivery Metrics
|
||||||
sequenceDiagram
|
|
||||||
participant I as Initiator
|
|
||||||
participant R as Responder
|
|
||||||
|
|
||||||
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
|
||||||
|
|
||||||
I->>R: -> e
|
## 7. Application Layer
|
||||||
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
|
||||||
|
|
||||||
R->>I: <- e, ee, s, es
|
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
||||||
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))
|
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
||||||
|
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
||||||
I->>R: -> s, se
|
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
||||||
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))
|
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
||||||
|
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
|
||||||
Note over I, R: Handshake complete. Transport keys derived.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handshake Flow:**
|
|
||||||
|
|
||||||
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
|
|
||||||
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
|
|
||||||
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
|
|
||||||
|
|
||||||
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
|
|
||||||
|
|
||||||
### 5.3. Session Management
|
|
||||||
|
|
||||||
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
|
|
||||||
* Creating sessions for new peers.
|
|
||||||
* Coordinating the handshake process to prevent race conditions.
|
|
||||||
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
|
|
||||||
* Periodically checking if sessions need to be re-keyed for enhanced security.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The BitChat Session and Application Protocol
|
|
||||||
|
|
||||||
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
|
|
||||||
|
|
||||||
### 6.1. Binary Packet Format (`BitchatPacket`)
|
|
||||||
|
|
||||||
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
|
|
||||||
|
|
||||||
| Field | Size (bytes) | Description |
|
|
||||||
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
|
|
||||||
| **Header** | **13** | **Fixed-size header** |
|
|
||||||
| Version | 1 | Protocol version (currently `1`). |
|
|
||||||
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
|
|
||||||
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
|
|
||||||
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
|
|
||||||
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
|
|
||||||
| Payload Length | 2 | `UInt16` length of the payload field. |
|
|
||||||
| **Variable** | **...** | **Variable-size fields** |
|
|
||||||
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
|
|
||||||
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
|
|
||||||
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
|
|
||||||
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
|
|
||||||
|
|
||||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
theme: dark
|
|
||||||
---
|
|
||||||
---
|
|
||||||
title: "BitchatPacket"
|
|
||||||
---
|
|
||||||
packet
|
|
||||||
+8: "Version"
|
|
||||||
+8: "Type"
|
|
||||||
+8: "TTL"
|
|
||||||
+64: "Timestamp"
|
|
||||||
+8: "Flags"
|
|
||||||
+16: "Payload Length"
|
|
||||||
+64: "Sender ID"
|
|
||||||
+64: "Recipient ID (optional)"
|
|
||||||
+48: "Payload (variable)"
|
|
||||||
+64: "Signature (optional)"
|
|
||||||
```
|
|
||||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
|
||||||
|
|
||||||
### 6.2. Application Message Format (`BitchatMessage`)
|
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
|
||||||
|
|
||||||
| Field | Size (bytes) | Description |
|
|
||||||
|---------------------|--------------|--------------------------------------------------------------------------|
|
|
||||||
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
|
|
||||||
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
|
|
||||||
| ID | 1 + len | `UUID` string for the message. |
|
|
||||||
| Sender | 1 + len | Nickname of the sender. |
|
|
||||||
| Content | 2 + len | The UTF-8 encoded message content. |
|
|
||||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
|
||||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
theme: dark
|
|
||||||
---
|
|
||||||
---
|
|
||||||
title: "BitchatMessage"
|
|
||||||
---
|
|
||||||
packet
|
|
||||||
+8: "Flags"
|
|
||||||
+64: "Timestamp"
|
|
||||||
+24: "ID (variable)"
|
|
||||||
+32: "Sender (variable)"
|
|
||||||
+32: "Content (variable)"
|
|
||||||
+32: "Original Sender (variable) (optional)"
|
|
||||||
+32: "Recipient Nickname (variable) (optional)"
|
|
||||||
```
|
|
||||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Message Routing and Propagation
|
|
||||||
|
|
||||||
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
|
|
||||||
|
|
||||||
### 7.1. Direct Connection
|
|
||||||
|
|
||||||
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
|
|
||||||
|
|
||||||
### 7.2. Efficient Gossip with Bloom Filters
|
|
||||||
|
|
||||||
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
|
|
||||||
|
|
||||||
The logic is as follows:
|
|
||||||
|
|
||||||
1. A peer receives a packet.
|
|
||||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
|
||||||
3. If the packet is new, its ID is added to the Bloom filter.
|
|
||||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
|
||||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
|
||||||
|
|
||||||
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
|
|
||||||
|
|
||||||
### 7.3. Time-To-Live (TTL)
|
|
||||||
|
|
||||||
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
|
|
||||||
|
|
||||||
### 7.4. Private vs. Broadcast Messages
|
|
||||||
|
|
||||||
The routing logic respects the confidentiality of private messages:
|
|
||||||
|
|
||||||
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
|
|
||||||
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
|
|
||||||
|
|
||||||
### 7.5. Message Reliability and Lifecycle
|
|
||||||
|
|
||||||
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
|
|
||||||
|
|
||||||
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
|
|
||||||
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
|
|
||||||
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
|
|
||||||
|
|
||||||
### 7.6. Fragmentation
|
|
||||||
|
|
||||||
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
|
|
||||||
|
|
||||||
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
|
|
||||||
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
|
|
||||||
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
|
|
||||||
|
|
||||||
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Security Considerations
|
## 8. Security Considerations
|
||||||
|
|
||||||
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
|
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||||
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||||
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
|
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||||
|
|
||||||
|
## 9. Future Work
|
||||||
|
|
||||||
|
* Prekey-based forward secrecy for courier envelopes.
|
||||||
|
* Couriered media beyond the 16 KiB text cap.
|
||||||
|
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||||
|
* Multi-hop courier routing informed by encounter history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Conclusion
|
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
||||||
|
|
||||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||||
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -157,15 +158,16 @@
|
|||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
@@ -225,6 +227,7 @@
|
|||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
|
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -305,6 +308,7 @@
|
|||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -317,7 +321,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1640;
|
LastUpgradeCheck = 2650;
|
||||||
};
|
};
|
||||||
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -345,7 +349,8 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||||
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -441,7 +446,6 @@
|
|||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -466,7 +470,6 @@
|
|||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -493,7 +496,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -518,7 +520,6 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
@@ -527,7 +528,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -560,7 +560,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -584,7 +583,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -610,7 +608,7 @@
|
|||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -620,7 +618,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -646,7 +643,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -656,7 +652,6 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -669,6 +664,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -702,6 +698,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -715,10 +712,10 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
@@ -738,7 +735,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -748,7 +744,6 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -761,6 +756,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -794,6 +790,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -813,11 +810,11 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
@@ -834,7 +831,6 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
@@ -843,7 +839,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -909,13 +904,17 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/BitFoundation;
|
||||||
|
};
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/Tor;
|
relativePath = localPackages/Arti;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
@@ -924,8 +923,8 @@
|
|||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = exactVersion;
|
||||||
minimumVersion = 0.21.1;
|
version = 0.21.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
@@ -936,6 +935,15 @@
|
|||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
productName = P256K;
|
productName = P256K;
|
||||||
};
|
};
|
||||||
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = BitFoundation;
|
||||||
|
};
|
||||||
|
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
|
||||||
|
productName = BitFoundation;
|
||||||
|
};
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitLogger;
|
productName = BitLogger;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2650"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2650"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SharedContentKind: String, Sendable, Equatable {
|
||||||
|
case text
|
||||||
|
case url
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||||
|
case active
|
||||||
|
case inactive
|
||||||
|
case background
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||||
|
case willStart
|
||||||
|
case willRestart
|
||||||
|
case didBecomeReady
|
||||||
|
case preferenceChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppEvent: Sendable, Equatable {
|
||||||
|
case launched
|
||||||
|
case startupCompleted
|
||||||
|
case scenePhaseChanged(RuntimeScenePhase)
|
||||||
|
case openedURL(String)
|
||||||
|
case sharedContentAccepted(SharedContentKind)
|
||||||
|
case notificationOpened(peerID: PeerID?)
|
||||||
|
case deepLinkOpened(String)
|
||||||
|
case torLifecycleChanged(TorLifecycleEvent)
|
||||||
|
case nostrRelayConnectionChanged(Bool)
|
||||||
|
case terminationRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AppEventStream {
|
||||||
|
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||||
|
|
||||||
|
func emit(_ event: AppEvent) {
|
||||||
|
for continuation in continuations.values {
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity key for a direct conversation. Equality and hashing use the
|
||||||
|
/// canonical `id` only; `routingPeerID` carries the transport-level peer ID
|
||||||
|
/// the conversation is keyed under (see `ConversationID.directPeer`).
|
||||||
|
struct PeerHandle: Sendable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let routingPeerID: PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerHandle: Equatable {
|
||||||
|
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerHandle: Hashable {
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConversationID: Hashable, Sendable {
|
||||||
|
case mesh
|
||||||
|
case geohash(String)
|
||||||
|
case direct(PeerHandle)
|
||||||
|
|
||||||
|
init(channelID: ChannelID) {
|
||||||
|
switch channelID {
|
||||||
|
case .mesh:
|
||||||
|
self = .mesh
|
||||||
|
case .location(let channel):
|
||||||
|
self = .geohash(channel.geohash.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppChromeModel: ObservableObject {
|
||||||
|
@Published private(set) var hasUnreadPrivateMessages = false
|
||||||
|
@Published var nickname: String
|
||||||
|
@Published var showingFingerprintFor: PeerID?
|
||||||
|
@Published var isAppInfoPresented = false
|
||||||
|
@Published var isLocationChannelsSheetPresented = false
|
||||||
|
@Published var isNoticesSheetPresented = false
|
||||||
|
/// When the sheet is opened for "notes left here" (empty mesh timeline),
|
||||||
|
/// it should land on the geo tab instead of the channel-derived default.
|
||||||
|
@Published var noticesSheetPrefersGeoTab = false
|
||||||
|
@Published var showBluetoothAlert = false
|
||||||
|
@Published var bluetoothAlertMessage = ""
|
||||||
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
@Published var showScreenshotPrivacyWarning = false
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
|
|
||||||
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.nickname = chatViewModel.nickname
|
||||||
|
|
||||||
|
bind(privateInboxModel: privateInboxModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldSuppressScreenshotNotification: Bool {
|
||||||
|
isLocationChannelsSheetPresented || isAppInfoPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String) {
|
||||||
|
self.nickname = nickname
|
||||||
|
if chatViewModel.nickname != nickname {
|
||||||
|
chatViewModel.nickname = nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndSaveNickname() {
|
||||||
|
chatViewModel.validateAndSaveNickname()
|
||||||
|
if nickname != chatViewModel.nickname {
|
||||||
|
nickname = chatViewModel.nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMostRelevantPrivateChat() {
|
||||||
|
chatViewModel.openMostRelevantPrivateChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showFingerprint(for peerID: PeerID) {
|
||||||
|
showingFingerprintFor = peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearFingerprint() {
|
||||||
|
showingFingerprintFor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAppInfo() {
|
||||||
|
isAppInfoPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentNotices(geoTab: Bool = false) {
|
||||||
|
noticesSheetPrefersGeoTab = geoTab
|
||||||
|
isNoticesSheetPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the mesh topology map model from the transport's gossiped
|
||||||
|
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||||
|
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||||
|
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||||
|
let mesh = chatViewModel.meshService
|
||||||
|
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||||
|
let nicknames = mesh.getPeerNicknames()
|
||||||
|
|
||||||
|
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||||
|
let isSelf = peerID == snapshot.localPeerID
|
||||||
|
let label: String
|
||||||
|
if isSelf {
|
||||||
|
label = chatViewModel.nickname
|
||||||
|
} else {
|
||||||
|
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
||||||
|
}
|
||||||
|
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
||||||
|
}
|
||||||
|
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
||||||
|
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerScreenshotPrivacyWarning() {
|
||||||
|
showScreenshotPrivacyWarning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func panicClearAllData() {
|
||||||
|
chatViewModel.panicClearAllData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(privateInboxModel: PrivateInboxModel) {
|
||||||
|
privateInboxModel.$unreadPeerIDs
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] unreadPeerIDs in
|
||||||
|
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] nickname in
|
||||||
|
guard let self, self.nickname != nickname else { return }
|
||||||
|
self.nickname = nickname
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$showBluetoothAlert
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$showBluetoothAlert)
|
||||||
|
|
||||||
|
chatViewModel.$bluetoothAlertMessage
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bluetoothAlertMessage)
|
||||||
|
|
||||||
|
chatViewModel.$bluetoothState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bluetoothState)
|
||||||
|
|
||||||
|
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Tor
|
||||||
|
import UserNotifications
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppRuntime: ObservableObject {
|
||||||
|
let chatViewModel: ChatViewModel
|
||||||
|
let events = AppEventStream()
|
||||||
|
/// Single source of truth for conversation message state and selection
|
||||||
|
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||||
|
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||||
|
let conversations: ConversationStore
|
||||||
|
let publicChatModel: PublicChatModel
|
||||||
|
let privateInboxModel: PrivateInboxModel
|
||||||
|
let privateConversationModel: PrivateConversationModel
|
||||||
|
let verificationModel: VerificationModel
|
||||||
|
let conversationUIModel: ConversationUIModel
|
||||||
|
let locationChannelsModel: LocationChannelsModel
|
||||||
|
let peerListModel: PeerListModel
|
||||||
|
let appChromeModel: AppChromeModel
|
||||||
|
let boardAlertsModel: BoardAlertsModel
|
||||||
|
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var started = false
|
||||||
|
private var lastNostrRelayConnectedState = false
|
||||||
|
private var didHandleInitialNostrConnection = false
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var didHandleInitialActive = false
|
||||||
|
private var didEnterBackground = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(
|
||||||
|
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||||
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
|
) {
|
||||||
|
self.idBridge = idBridge
|
||||||
|
let conversations = ConversationStore()
|
||||||
|
let peerIdentityStore = PeerIdentityStore()
|
||||||
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
|
let locationManager = LocationChannelManager.shared
|
||||||
|
self.conversations = conversations
|
||||||
|
self.chatViewModel = ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain),
|
||||||
|
conversations: conversations,
|
||||||
|
peerIdentityStore: peerIdentityStore,
|
||||||
|
locationPresenceStore: locationPresenceStore,
|
||||||
|
locationManager: locationManager
|
||||||
|
)
|
||||||
|
self.publicChatModel = PublicChatModel(conversations: conversations)
|
||||||
|
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||||
|
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||||
|
self.privateConversationModel = PrivateConversationModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
conversations: conversations,
|
||||||
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
|
peerIdentityStore: peerIdentityStore
|
||||||
|
)
|
||||||
|
self.verificationModel = VerificationModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateConversationModel: self.privateConversationModel,
|
||||||
|
peerIdentityStore: peerIdentityStore
|
||||||
|
)
|
||||||
|
self.conversationUIModel = ConversationUIModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateConversationModel: self.privateConversationModel,
|
||||||
|
conversations: conversations
|
||||||
|
)
|
||||||
|
self.peerListModel = PeerListModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
conversations: conversations,
|
||||||
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
|
peerIdentityStore: peerIdentityStore,
|
||||||
|
locationPresenceStore: locationPresenceStore
|
||||||
|
)
|
||||||
|
self.appChromeModel = AppChromeModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateInboxModel: self.privateInboxModel
|
||||||
|
)
|
||||||
|
let chatViewModel = self.chatViewModel
|
||||||
|
self.boardAlertsModel = BoardAlertsModel(
|
||||||
|
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
||||||
|
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
||||||
|
dependencies: BoardAlertsModel.Dependencies(
|
||||||
|
isOwnPost: { post in
|
||||||
|
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
||||||
|
return !key.isEmpty && key == post.authorSigningKey
|
||||||
|
},
|
||||||
|
emitSystemLine: { content, geohash in
|
||||||
|
if geohash.isEmpty {
|
||||||
|
chatViewModel.addMeshOnlySystemMessage(content)
|
||||||
|
} else {
|
||||||
|
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
|
bindRuntimeObservers()
|
||||||
|
NotificationDelegate.shared.runtime = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else {
|
||||||
|
checkForSharedContent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
started = true
|
||||||
|
NotificationDelegate.shared.runtime = self
|
||||||
|
VerificationService.shared.configure(with: chatViewModel.meshService)
|
||||||
|
announceInitialTorStatusIfNeeded()
|
||||||
|
|
||||||
|
Task(priority: .utility) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let nickname = await MainActor.run { self.chatViewModel.nickname }
|
||||||
|
let npub = await MainActor.run {
|
||||||
|
try? self.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
|
GeohashPresenceService.shared.start()
|
||||||
|
checkForSharedContent()
|
||||||
|
|
||||||
|
record(.launched)
|
||||||
|
record(.startupCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpenURL(_ url: URL) {
|
||||||
|
record(.openedURL(url.absoluteString))
|
||||||
|
|
||||||
|
if url.scheme == "bitchat", url.host == "share" {
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDidBecomeActiveNotification() {
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
func handleMacDidBecomeActiveNotification() {
|
||||||
|
record(.scenePhaseChanged(.active))
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||||
|
switch newPhase {
|
||||||
|
case .background:
|
||||||
|
record(.scenePhaseChanged(.background))
|
||||||
|
TorManager.shared.setAppForeground(false)
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
|
chatViewModel.endGeohashSampling()
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
didEnterBackground = true
|
||||||
|
|
||||||
|
case .active:
|
||||||
|
record(.scenePhaseChanged(.active))
|
||||||
|
chatViewModel.meshService.startServices()
|
||||||
|
TorManager.shared.setAppForeground(true)
|
||||||
|
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
|
||||||
|
|
||||||
|
if didHandleInitialActive && didEnterBackground {
|
||||||
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
didHandleInitialActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
didEnterBackground = false
|
||||||
|
|
||||||
|
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
|
||||||
|
Task.detached {
|
||||||
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
|
await MainActor.run {
|
||||||
|
TorURLSession.shared.rebuild()
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
|
||||||
|
case .inactive:
|
||||||
|
record(.scenePhaseChanged(.inactive))
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func applicationWillTerminate() {
|
||||||
|
record(.terminationRequested)
|
||||||
|
chatViewModel.applicationWillTerminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNotificationResponse(
|
||||||
|
identifier: String,
|
||||||
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) {
|
||||||
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
|
chatViewModel.sendMeshWave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
|
record(.notificationOpened(peerID: peerID))
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
|
||||||
|
record(.deepLinkOpened(deepLink))
|
||||||
|
openExternalURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationOptions(
|
||||||
|
forNotificationIdentifier identifier: String,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
|
if conversations.selectedPrivatePeerID == peerID {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [.banner, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
|
let deepLink = userInfo["deeplink"] as? String,
|
||||||
|
let geohash = deepLink.components(separatedBy: "/").last,
|
||||||
|
case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||||
|
channel.geohash == geohash {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.banner, .sound]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppRuntime {
|
||||||
|
func bindRuntimeObservers() {
|
||||||
|
NostrRelayManager.shared.$isConnected
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isConnected in
|
||||||
|
self?.handleNostrRelayConnectionChanged(isConnected)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.willRestart))
|
||||||
|
self?.chatViewModel.handleTorWillRestart()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||||
|
self?.chatViewModel.handleTorDidBecomeReady()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.willStart))
|
||||||
|
self?.chatViewModel.handleTorWillStart()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] notification in
|
||||||
|
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||||
|
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.handleScreenshotCaptured()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForSharedContent() {
|
||||||
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||||
|
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||||
|
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||||
|
|
||||||
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
|
|
||||||
|
switch contentKind {
|
||||||
|
case .url:
|
||||||
|
if let data = sharedContent.data(using: .utf8),
|
||||||
|
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||||
|
let url = urlData["url"] {
|
||||||
|
chatViewModel.sendMessage(url)
|
||||||
|
} else {
|
||||||
|
chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
case .text:
|
||||||
|
chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
record(.sharedContentAccepted(contentKind))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||||
|
record(.nostrRelayConnectionChanged(isConnected))
|
||||||
|
|
||||||
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
|
guard started, becameConnected else { return }
|
||||||
|
|
||||||
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
|
didHandleInitialNostrConnection = true
|
||||||
|
|
||||||
|
if !chatViewModel.nostrHandlersSetup {
|
||||||
|
chatViewModel.setupNostrMessageHandling()
|
||||||
|
chatViewModel.nostrHandlersSetup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isInitialConnection else { return }
|
||||||
|
|
||||||
|
chatViewModel.resubscribeCurrentGeohash()
|
||||||
|
chatViewModel.geoChannelCoordinator?.refreshSampling()
|
||||||
|
}
|
||||||
|
|
||||||
|
func announceInitialTorStatusIfNeeded() {
|
||||||
|
if TorManager.shared.torEnforced &&
|
||||||
|
!chatViewModel.torStatusAnnounced &&
|
||||||
|
TorManager.shared.isAutoStartAllowed() {
|
||||||
|
chatViewModel.torStatusAnnounced = true
|
||||||
|
chatViewModel.addGeohashOnlySystemMessage(
|
||||||
|
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||||
|
)
|
||||||
|
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||||
|
chatViewModel.torStatusAnnounced = true
|
||||||
|
chatViewModel.addGeohashOnlySystemMessage(
|
||||||
|
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScreenshotCaptured() {
|
||||||
|
if appChromeModel.isLocationChannelsSheetPresented {
|
||||||
|
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if appChromeModel.isAppInfoPresented {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.handleScreenshotCaptured()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openExternalURL(_ url: URL) {
|
||||||
|
#if os(iOS)
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
#else
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ event: AppEvent) {
|
||||||
|
Task {
|
||||||
|
await events.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
//
|
||||||
|
// ConversationStore.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Single source of truth for conversation message state (see
|
||||||
|
// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per
|
||||||
|
// `ConversationID`; all mutations flow through the store's intent API and
|
||||||
|
// every mutation emits a `ConversationChange` after state is consistent.
|
||||||
|
//
|
||||||
|
// The store also owns conversation selection: the active public channel and
|
||||||
|
// the selected private peer (the two UI selection axes) plus the derived
|
||||||
|
// `selectedConversationID`.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Conversation
|
||||||
|
|
||||||
|
/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`).
|
||||||
|
///
|
||||||
|
/// Publishing granularity is per conversation: views observe ONE
|
||||||
|
/// `Conversation` object, so an append to chat A never invalidates observers
|
||||||
|
/// of chat B.
|
||||||
|
///
|
||||||
|
/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent
|
||||||
|
/// API may mutate a conversation, keeping the store the sole writer.
|
||||||
|
@MainActor
|
||||||
|
final class Conversation: ObservableObject, Identifiable {
|
||||||
|
let id: ConversationID
|
||||||
|
/// Maximum retained messages; oldest are trimmed on overflow.
|
||||||
|
let cap: Int
|
||||||
|
|
||||||
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
|
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||||
|
/// delivery-status lookup. Kept in sync on every mutation:
|
||||||
|
/// - tail append: single insert
|
||||||
|
/// - out-of-order insert: suffix reindex from the insertion point
|
||||||
|
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||||
|
/// rebuild does not change the asymptotics, and trim only happens once
|
||||||
|
/// the cap (1337) is reached. Simple and correct beats the
|
||||||
|
/// offset-tracking alternative here.
|
||||||
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
|
|
||||||
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
|
self.id = id
|
||||||
|
self.cap = max(1, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Reads
|
||||||
|
|
||||||
|
func containsMessage(withID messageID: String) -> Bool {
|
||||||
|
indexByMessageID[messageID] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
|
return messages[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All message IDs currently in this conversation (unordered).
|
||||||
|
var messageIDs: Dictionary<String, Int>.Keys {
|
||||||
|
indexByMessageID.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Store-internal mutations
|
||||||
|
|
||||||
|
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
|
||||||
|
/// evicted by the cap so the store can keep its message-ID →
|
||||||
|
/// conversation map exact.
|
||||||
|
fileprivate struct InsertResult {
|
||||||
|
let inserted: Bool
|
||||||
|
let trimmedMessageIDs: [String]
|
||||||
|
|
||||||
|
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate enum UpsertOutcome {
|
||||||
|
case appended(trimmedMessageIDs: [String])
|
||||||
|
case updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a message in timestamp order, deduplicating by message ID.
|
||||||
|
/// Fast path appends when the timestamp is >= the current tail;
|
||||||
|
/// otherwise a binary search finds the upper-bound insertion point so
|
||||||
|
/// arrival order is preserved among equal timestamps.
|
||||||
|
/// Reports `inserted: false` if a message with the same ID already exists.
|
||||||
|
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
|
||||||
|
guard indexByMessageID[message.id] == nil else { return .duplicate }
|
||||||
|
|
||||||
|
if let last = messages.last, message.timestamp < last.timestamp {
|
||||||
|
let index = insertionIndex(for: message.timestamp)
|
||||||
|
messages.insert(message, at: index)
|
||||||
|
reindex(from: index)
|
||||||
|
} else {
|
||||||
|
messages.append(message)
|
||||||
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace-or-append by message ID. An existing message keeps its
|
||||||
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
|
if let index = indexByMessageID[message.id] {
|
||||||
|
messages[index] = message
|
||||||
|
return .updated
|
||||||
|
}
|
||||||
|
let result = insert(message)
|
||||||
|
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a delivery status keyed by message ID, honoring the
|
||||||
|
/// no-downgrade rule (the SOLE enforcement point — every delivery
|
||||||
|
/// update flows through the store): equal statuses are skipped, and
|
||||||
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
|
/// Returns `true` when the status was applied.
|
||||||
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
|
let message = messages[index]
|
||||||
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
|
message.deliveryStatus = status
|
||||||
|
// BitchatMessage is a reference type; write back through the
|
||||||
|
// subscript so the @Published wrapper emits.
|
||||||
|
messages[index] = message
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Republishes a message without changing state. Used for mirrored
|
||||||
|
/// copies that share a BitchatMessage instance: the first conversation's
|
||||||
|
/// status apply mutated the shared object, so this conversation's
|
||||||
|
/// observers still need an @Published emission to re-render.
|
||||||
|
@discardableResult
|
||||||
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
|
messages[index] = messages[index]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
fileprivate func setUnread(_ unread: Bool) -> Bool {
|
||||||
|
guard isUnread != unread else { return false }
|
||||||
|
isUnread = unread
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
|
/// `nil` when no message with that ID exists.
|
||||||
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
|
let removed = messages.remove(at: index)
|
||||||
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
|
reindex(from: index)
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every message matching `predicate`. Returns the removed
|
||||||
|
/// message IDs (empty when nothing matched).
|
||||||
|
fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] {
|
||||||
|
var removedIDs: [String] = []
|
||||||
|
messages.removeAll { message in
|
||||||
|
guard predicate(message) else { return false }
|
||||||
|
removedIDs.append(message.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard !removedIDs.isEmpty else { return [] }
|
||||||
|
for id in removedIDs {
|
||||||
|
indexByMessageID.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
reindex(from: 0)
|
||||||
|
return removedIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func clearMessages() {
|
||||||
|
messages.removeAll()
|
||||||
|
indexByMessageID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Diagnostics
|
||||||
|
|
||||||
|
/// Appends human-readable invariant violations for this conversation
|
||||||
|
/// (empty when healthy): the ID index must be the exact inverse of the
|
||||||
|
/// messages array, the cap must hold, and timestamps must be
|
||||||
|
/// non-decreasing (equal timestamps keep arrival order, so only strict
|
||||||
|
/// inversions are violations). O(messages); allocates only on violation.
|
||||||
|
fileprivate func collectInvariantViolations(into violations: inout [String], label: String) {
|
||||||
|
if indexByMessageID.count != messages.count {
|
||||||
|
violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages")
|
||||||
|
}
|
||||||
|
if messages.count > cap {
|
||||||
|
violations.append("\(label): \(messages.count) messages exceeds cap \(cap)")
|
||||||
|
}
|
||||||
|
var previousTimestamp: Date?
|
||||||
|
for position in messages.indices {
|
||||||
|
let message = messages[position]
|
||||||
|
// Count equality + every message resolving to its own position
|
||||||
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
|
if let index = indexByMessageID[message.id] {
|
||||||
|
if index != position {
|
||||||
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
|
}
|
||||||
|
if let previousTimestamp, message.timestamp < previousTimestamp {
|
||||||
|
violations.append("\(label): timestamp order violated at \(position)")
|
||||||
|
}
|
||||||
|
previousTimestamp = message.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Internals
|
||||||
|
|
||||||
|
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
|
||||||
|
guard let current else { return false }
|
||||||
|
if current == new { return true }
|
||||||
|
|
||||||
|
switch (current, new) {
|
||||||
|
case (.read, .delivered), (.read, .sent):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upper-bound binary search: first index whose timestamp is strictly
|
||||||
|
/// greater than `timestamp`, so equal-timestamp messages keep arrival
|
||||||
|
/// order.
|
||||||
|
private func insertionIndex(for timestamp: Date) -> Int {
|
||||||
|
var low = 0
|
||||||
|
var high = messages.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high) / 2
|
||||||
|
if messages[mid].timestamp <= timestamp {
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reindex(from start: Int) {
|
||||||
|
for index in start..<messages.count {
|
||||||
|
indexByMessageID[messages[index].id] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
|
private func trimIfNeeded() -> [String] {
|
||||||
|
guard messages.count > cap else { return [] }
|
||||||
|
let overflow = messages.count - cap
|
||||||
|
let trimmedIDs = messages.prefix(overflow).map(\.id)
|
||||||
|
for id in trimmedIDs {
|
||||||
|
indexByMessageID.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
messages.removeFirst(overflow)
|
||||||
|
reindex(from: 0)
|
||||||
|
return trimmedIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ConversationChange
|
||||||
|
|
||||||
|
/// Typed mutation events for non-UI consumers (delivery tracking,
|
||||||
|
/// notifications, sync) that need "something changed in conversation X"
|
||||||
|
/// without subscribing to whole message arrays. Emitted on the store's
|
||||||
|
/// `changes` subject AFTER the corresponding state is consistent.
|
||||||
|
enum ConversationChange {
|
||||||
|
case appended(ConversationID, BitchatMessage)
|
||||||
|
case updated(ConversationID, messageID: String)
|
||||||
|
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
|
||||||
|
case messageRemoved(ConversationID, messageID: String)
|
||||||
|
case cleared(ConversationID)
|
||||||
|
case removed(ConversationID)
|
||||||
|
case migrated(from: ConversationID, to: ConversationID)
|
||||||
|
case unreadChanged(ConversationID, isUnread: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ConversationStore
|
||||||
|
|
||||||
|
/// Sole writer and sole holder of conversation message state. All mutations
|
||||||
|
/// go through the intent API below; backing collections are `private(set)`.
|
||||||
|
/// Reads are synchronous — writers and readers share the main actor, so
|
||||||
|
/// after an intent returns every observer sees the result.
|
||||||
|
@MainActor
|
||||||
|
final class ConversationStore: ObservableObject {
|
||||||
|
/// Conversation creation order; published so list-style consumers can
|
||||||
|
/// observe conversations appearing/disappearing without rebuilding from
|
||||||
|
/// the dictionary.
|
||||||
|
@Published private(set) var conversationIDs: [ConversationID] = []
|
||||||
|
@Published private(set) var selectedConversationID: ConversationID?
|
||||||
|
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||||
|
|
||||||
|
// MARK: Selection state
|
||||||
|
// The two UI selection axes: which public channel is active, and which
|
||||||
|
// private chat (if any) is open on top of it. `selectedConversationID`
|
||||||
|
// is derived: the open private chat wins, otherwise the active public
|
||||||
|
// channel's conversation. Mutate via `setActiveChannel` /
|
||||||
|
// `setSelectedPrivatePeer` only.
|
||||||
|
|
||||||
|
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||||
|
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||||
|
|
||||||
|
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
||||||
|
|
||||||
|
/// Store-level message-ID → conversation-membership map for ID-only
|
||||||
|
/// lookups (delivery receipts arrive with a message ID, not a
|
||||||
|
/// conversation). Maintained incrementally at every mutation point —
|
||||||
|
/// all mutation is centralized in the intent API below, so the map is
|
||||||
|
/// exact, never scanned or rebuilt.
|
||||||
|
///
|
||||||
|
/// The value is a `Set` because a private message can legitimately live
|
||||||
|
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
|
||||||
|
/// message into both the stable-key and ephemeral-peer chats
|
||||||
|
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
|
||||||
|
/// copies.
|
||||||
|
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
|
||||||
|
|
||||||
|
/// Monotonic count of messages inserted into any conversation (appends,
|
||||||
|
/// upsert-appends, migration inserts). Field-observability only: the
|
||||||
|
/// periodic store audit folds the delta into its heartbeat line so logs
|
||||||
|
/// carry throughput context. Never read on a hot path.
|
||||||
|
private(set) var appendCount: Int = 0
|
||||||
|
|
||||||
|
/// Sample counter for the mirrored-republish debug log in the ID-only
|
||||||
|
/// `setDeliveryStatus` fan-out (first + every Nth occurrence).
|
||||||
|
private var mirroredRepublishLogCount = 0
|
||||||
|
|
||||||
|
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||||
|
|
||||||
|
// MARK: Intent API
|
||||||
|
|
||||||
|
/// Returns the conversation for `id`, creating it (with the cap policy
|
||||||
|
/// for its kind) on first access.
|
||||||
|
@discardableResult
|
||||||
|
func conversation(for id: ConversationID) -> Conversation {
|
||||||
|
if let existing = conversationsByID[id] {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let conversation = Conversation(id: id, cap: Self.cap(for: id))
|
||||||
|
conversationsByID[id] = conversation
|
||||||
|
conversationIDs.append(id)
|
||||||
|
return conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a message in timestamp order. Returns `false` (and emits
|
||||||
|
/// nothing) if a message with the same ID is already present.
|
||||||
|
@discardableResult
|
||||||
|
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
let result = conversation.insert(message)
|
||||||
|
guard result.inserted else { return false }
|
||||||
|
registerMessageID(message.id, in: id)
|
||||||
|
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
|
||||||
|
changes.send(.appended(id, message))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace-or-append by message ID (media progress, edits).
|
||||||
|
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
switch conversation.upsert(message) {
|
||||||
|
case .appended(let trimmedMessageIDs):
|
||||||
|
registerMessageID(message.id, in: id)
|
||||||
|
unregisterMessageIDs(trimmedMessageIDs, from: id)
|
||||||
|
changes.send(.appended(id, message))
|
||||||
|
case .updated:
|
||||||
|
changes.send(.updated(id, messageID: message.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a delivery status keyed by message ID. Returns `false` when
|
||||||
|
/// the message is unknown or the update would downgrade the status
|
||||||
|
/// (read beats delivered beats sent).
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool {
|
||||||
|
guard let conversation = conversationsByID[id],
|
||||||
|
conversation.applyDeliveryStatus(status, forMessageID: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a delivery status to EVERY conversation containing
|
||||||
|
/// `messageID` (ID-only — delivery receipts don't know conversations;
|
||||||
|
/// mirrored private copies live in two direct chats). Returns `false`
|
||||||
|
/// when the message is unknown or no copy changed (equal status or
|
||||||
|
/// downgrade — read beats delivered beats sent).
|
||||||
|
///
|
||||||
|
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
|
||||||
|
/// instance are mutated by the first conversation's apply. The skipped
|
||||||
|
/// conversations still hold the changed message, so they get an explicit
|
||||||
|
/// republish and `.statusChanged` event - otherwise a view observing the
|
||||||
|
/// mirrored conversation would render stale status. Distinct copies whose
|
||||||
|
/// update was genuinely rejected (downgrade) are left untouched, guarded
|
||||||
|
/// by status equality.
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
|
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||||
|
var applied = false
|
||||||
|
var skipped: [ConversationID] = []
|
||||||
|
for id in ids {
|
||||||
|
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
|
||||||
|
applied = true
|
||||||
|
} else {
|
||||||
|
skipped.append(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard applied else { return false }
|
||||||
|
for id in skipped {
|
||||||
|
guard let conversation = conversationsByID[id],
|
||||||
|
conversation.message(withID: messageID)?.deliveryStatus == status,
|
||||||
|
conversation.republishMessage(withID: messageID) else { continue }
|
||||||
|
// Field proof the mirrored-copy republish path actually fires;
|
||||||
|
// sampled (first + every Nth) so mirrored chats can't spam logs.
|
||||||
|
mirroredRepublishLogCount += 1
|
||||||
|
if mirroredRepublishLogCount == 1
|
||||||
|
|| mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current delivery status of `messageID` in whichever conversation
|
||||||
|
/// holds it (mirrored copies share status — see `setDeliveryStatus`).
|
||||||
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
|
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
|
||||||
|
for id in ids {
|
||||||
|
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every conversation currently containing `messageID` (empty when the
|
||||||
|
/// message is unknown).
|
||||||
|
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
|
||||||
|
conversationIDsByMessageID[messageID] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(_ id: ConversationID) {
|
||||||
|
guard unreadConversations.contains(id) else { return }
|
||||||
|
unreadConversations.remove(id)
|
||||||
|
conversationsByID[id]?.setUnread(false)
|
||||||
|
changes.send(.unreadChanged(id, isUnread: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func markUnread(_ id: ConversationID) {
|
||||||
|
guard !unreadConversations.contains(id) else { return }
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
unreadConversations.insert(id)
|
||||||
|
conversation.setUnread(true)
|
||||||
|
changes.send(.unreadChanged(id, isUnread: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a conversation (creating it if needed) or clears the
|
||||||
|
/// selection with `nil`.
|
||||||
|
func select(_ id: ConversationID?) {
|
||||||
|
if let id {
|
||||||
|
conversation(for: id)
|
||||||
|
}
|
||||||
|
guard selectedConversationID != id else { return }
|
||||||
|
selectedConversationID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active public channel. While no private chat is open
|
||||||
|
/// the selection follows the channel.
|
||||||
|
func setActiveChannel(_ channelID: ChannelID) {
|
||||||
|
if activeChannel != channelID {
|
||||||
|
activeChannel = channelID
|
||||||
|
}
|
||||||
|
refreshDerivedSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a private chat (`nil` closes it, returning the selection to the
|
||||||
|
/// active public channel's conversation).
|
||||||
|
func setSelectedPrivatePeer(_ peerID: PeerID?) {
|
||||||
|
if selectedPrivatePeerID != peerID {
|
||||||
|
selectedPrivatePeerID = peerID
|
||||||
|
}
|
||||||
|
refreshDerivedSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshDerivedSelection() {
|
||||||
|
if let peerID = selectedPrivatePeerID {
|
||||||
|
select(.directPeer(peerID))
|
||||||
|
} else {
|
||||||
|
select(ConversationID(channelID: activeChannel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves all messages from `source` into `destination` (the
|
||||||
|
/// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves
|
||||||
|
/// timestamp order, carries unread state over, and hands off the
|
||||||
|
/// selection — mirroring `ChatPrivateConversationCoordinator`'s
|
||||||
|
/// migration semantics. The source conversation is removed. Emits a
|
||||||
|
/// single `.migrated(from:to:)` once the whole move is consistent.
|
||||||
|
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
|
||||||
|
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
|
||||||
|
|
||||||
|
let destinationConversation = conversation(for: destination)
|
||||||
|
for message in sourceConversation.messages {
|
||||||
|
let result = destinationConversation.insert(message)
|
||||||
|
guard result.inserted else { continue }
|
||||||
|
registerMessageID(message.id, in: destination)
|
||||||
|
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
|
||||||
|
}
|
||||||
|
for messageID in sourceConversation.messageIDs {
|
||||||
|
unregisterMessageID(messageID, from: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasUnread = unreadConversations.contains(source)
|
||||||
|
let wasSelected = selectedConversationID == source
|
||||||
|
|
||||||
|
conversationsByID.removeValue(forKey: source)
|
||||||
|
conversationIDs.removeAll { $0 == source }
|
||||||
|
unreadConversations.remove(source)
|
||||||
|
|
||||||
|
if wasUnread, !unreadConversations.contains(destination) {
|
||||||
|
unreadConversations.insert(destination)
|
||||||
|
destinationConversation.setUnread(true)
|
||||||
|
}
|
||||||
|
if wasSelected {
|
||||||
|
selectedConversationID = destination
|
||||||
|
// Keep the private-peer selection axis consistent with the
|
||||||
|
// handed-off selection.
|
||||||
|
if let peerID = selectedPrivatePeerID,
|
||||||
|
source == .directPeer(peerID),
|
||||||
|
case .direct(let destinationHandle) = destination {
|
||||||
|
selectedPrivatePeerID = destinationHandle.routingPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.send(.migrated(from: source, to: destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a single message by ID from a conversation. Returns the
|
||||||
|
/// removed message, or `nil` (emitting nothing) when the conversation or
|
||||||
|
/// message is unknown.
|
||||||
|
@discardableResult
|
||||||
|
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
|
||||||
|
guard let conversation = conversationsByID[id],
|
||||||
|
let removed = conversation.remove(messageID: messageID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
unregisterMessageID(messageID, from: id)
|
||||||
|
changes.send(.messageRemoved(id, messageID: messageID))
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every message matching `predicate` from a conversation,
|
||||||
|
/// emitting one `.messageRemoved` per removed message after the
|
||||||
|
/// conversation is consistent. No-op for unknown conversations.
|
||||||
|
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
||||||
|
guard let conversation = conversationsByID[id] else { return }
|
||||||
|
let removedIDs = conversation.removeAll(where: predicate)
|
||||||
|
unregisterMessageIDs(removedIDs, from: id)
|
||||||
|
for messageID in removedIDs {
|
||||||
|
changes.send(.messageRemoved(id, messageID: messageID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empties a conversation's timeline but keeps the conversation (and
|
||||||
|
/// its unread/selection state) alive.
|
||||||
|
func clear(_ id: ConversationID) {
|
||||||
|
guard let conversation = conversationsByID[id] else { return }
|
||||||
|
for messageID in conversation.messageIDs {
|
||||||
|
unregisterMessageID(messageID, from: id)
|
||||||
|
}
|
||||||
|
conversation.clearMessages()
|
||||||
|
changes.send(.cleared(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a conversation entirely, including unread state; clears the
|
||||||
|
/// selection if it pointed at the removed conversation.
|
||||||
|
func removeConversation(_ id: ConversationID) {
|
||||||
|
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
|
||||||
|
for messageID in conversation.messageIDs {
|
||||||
|
unregisterMessageID(messageID, from: id)
|
||||||
|
}
|
||||||
|
conversationIDs.removeAll { $0 == id }
|
||||||
|
unreadConversations.remove(id)
|
||||||
|
if selectedConversationID == id {
|
||||||
|
selectedConversationID = nil
|
||||||
|
}
|
||||||
|
changes.send(.removed(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
let removedIDs = conversationIDs
|
||||||
|
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
|
||||||
|
|
||||||
|
conversationsByID.removeAll()
|
||||||
|
conversationIDs.removeAll()
|
||||||
|
unreadConversations.removeAll()
|
||||||
|
conversationIDsByMessageID.removeAll()
|
||||||
|
if selectedConversationID != nil {
|
||||||
|
selectedConversationID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in removedIDs {
|
||||||
|
changes.send(.removed(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Diagnostics
|
||||||
|
|
||||||
|
/// Total messages across all conversations. O(#conversations) — heartbeat
|
||||||
|
/// logging only, never a hot path.
|
||||||
|
var totalMessageCount: Int {
|
||||||
|
conversationsByID.values.reduce(0) { $0 + $1.messages.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of distinct message IDs in the store-level membership map.
|
||||||
|
var messageIDMapCount: Int {
|
||||||
|
conversationIDsByMessageID.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the store's correctness invariants and returns human-readable
|
||||||
|
/// violations (empty = healthy). Intended for a periodic field audit:
|
||||||
|
/// O(total messages) and allocation-free while healthy. Checks:
|
||||||
|
/// - the `conversationIDs` ordering array matches `conversationsByID`
|
||||||
|
/// - per conversation: ID index exact, cap held, timestamp order
|
||||||
|
/// (see `Conversation.collectInvariantViolations`)
|
||||||
|
/// - the message-ID → conversation map matches reality exactly: every
|
||||||
|
/// mapped membership points at a live conversation actually holding
|
||||||
|
/// the message, and total memberships equal total messages (with the
|
||||||
|
/// forward check, equality proves no conversation message is missing
|
||||||
|
/// from the map)
|
||||||
|
/// - `unreadConversations` only references existing conversations
|
||||||
|
/// - `selectedConversationID`, when set, references an existing
|
||||||
|
/// conversation (`select(_:)` creates on selection and
|
||||||
|
/// `removeConversation`/`clearAll` clear it, so existence is the
|
||||||
|
/// invariant for both the channel-derived and direct-peer cases)
|
||||||
|
func auditInvariants() -> [String] {
|
||||||
|
var violations: [String] = []
|
||||||
|
|
||||||
|
if conversationIDs.count != conversationsByID.count {
|
||||||
|
violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)")
|
||||||
|
}
|
||||||
|
for id in conversationIDs where conversationsByID[id] == nil {
|
||||||
|
violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalMessages = 0
|
||||||
|
for (id, conversation) in conversationsByID {
|
||||||
|
totalMessages += conversation.messages.count
|
||||||
|
conversation.collectInvariantViolations(into: &violations, label: id.auditDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalMappedMemberships = 0
|
||||||
|
for (messageID, ids) in conversationIDsByMessageID {
|
||||||
|
totalMappedMemberships += ids.count
|
||||||
|
if ids.isEmpty {
|
||||||
|
violations.append("message map: \(messageID.prefix(8))… has an empty membership set")
|
||||||
|
}
|
||||||
|
for id in ids {
|
||||||
|
guard let conversation = conversationsByID[id] else {
|
||||||
|
violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !conversation.containsMessage(withID: messageID) {
|
||||||
|
violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalMappedMemberships != totalMessages {
|
||||||
|
violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in unreadConversations where conversationsByID[id] == nil {
|
||||||
|
violations.append("unreadConversations contains unknown conversation \(id.auditDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selected = selectedConversationID, conversationsByID[selected] == nil {
|
||||||
|
violations.append("selectedConversationID \(selected.auditDescription) has no conversation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Internals
|
||||||
|
|
||||||
|
private func registerMessageID(_ messageID: String, in id: ConversationID) {
|
||||||
|
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||||
|
// Single choke point for every successful insertion (append, upsert
|
||||||
|
// append, migration insert) — the audit heartbeat's throughput delta.
|
||||||
|
appendCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||||
|
guard var ids = conversationIDsByMessageID[messageID] else { return }
|
||||||
|
ids.remove(id)
|
||||||
|
if ids.isEmpty {
|
||||||
|
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||||
|
} else {
|
||||||
|
conversationIDsByMessageID[messageID] = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
|
||||||
|
for messageID in messageIDs {
|
||||||
|
unregisterMessageID(messageID, from: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cap(for id: ConversationID) -> Int {
|
||||||
|
switch id {
|
||||||
|
case .mesh:
|
||||||
|
return TransportConfig.meshTimelineCap
|
||||||
|
case .geohash:
|
||||||
|
return TransportConfig.geoTimelineCap
|
||||||
|
case .direct:
|
||||||
|
return TransportConfig.privateChatCap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Direct-conversation keying + derived views
|
||||||
|
|
||||||
|
extension ConversationID {
|
||||||
|
/// Direct-conversation ID keyed by the *raw* routing peer ID.
|
||||||
|
///
|
||||||
|
/// Direct conversations are deliberately keyed per `PeerID`, not per
|
||||||
|
/// resolved identity: the private-chat coordinators mirror messages into
|
||||||
|
/// both the ephemeral and stable peer's conversations
|
||||||
|
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
|
||||||
|
/// explicitly, so a raw lookup by whichever peer ID is selected always
|
||||||
|
/// finds the right timeline without an identity-resolution layer.
|
||||||
|
static func directPeer(_ peerID: PeerID) -> ConversationID {
|
||||||
|
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConversationStore {
|
||||||
|
/// All direct conversations' messages keyed by routing peer ID — the
|
||||||
|
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
|
||||||
|
/// Values are the conversations' backing arrays (COW), so building this
|
||||||
|
/// is O(#conversations), not O(#messages).
|
||||||
|
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
|
||||||
|
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
messagesByPeerID.reserveCapacity(conversationsByID.count)
|
||||||
|
for (id, conversation) in conversationsByID {
|
||||||
|
guard case .direct(let handle) = id else { continue }
|
||||||
|
messagesByPeerID[handle.routingPeerID] = conversation.messages
|
||||||
|
}
|
||||||
|
return messagesByPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unread direct conversations as routing peer IDs — the shape
|
||||||
|
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
|
||||||
|
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
|
||||||
|
var peerIDs = Set<PeerID>()
|
||||||
|
for id in unreadConversations {
|
||||||
|
guard case .direct(let handle) = id else { continue }
|
||||||
|
peerIDs.insert(handle.routingPeerID)
|
||||||
|
}
|
||||||
|
return peerIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when any direct conversation contains a message with `messageID`
|
||||||
|
/// (O(1) via the store-level message-ID → conversation map).
|
||||||
|
func directConversationsContainMessage(withID messageID: String) -> Bool {
|
||||||
|
conversationIDs(forMessageID: messageID).contains { id in
|
||||||
|
if case .direct = id { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message IDs across all direct conversations (read-receipt pruning
|
||||||
|
/// keeps only receipts whose messages still exist).
|
||||||
|
func directMessageIDs() -> Set<String> {
|
||||||
|
var messageIDs = Set<String>()
|
||||||
|
for (id, conversation) in conversationsByID {
|
||||||
|
guard case .direct = id else { continue }
|
||||||
|
messageIDs.formUnion(conversation.messageIDs)
|
||||||
|
}
|
||||||
|
return messageIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Diagnostics support
|
||||||
|
|
||||||
|
extension ConversationID {
|
||||||
|
/// Short, log-safe description for audit/diagnostic lines. Direct
|
||||||
|
/// conversations truncate the handle so full peer keys never hit logs.
|
||||||
|
fileprivate var auditDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .mesh:
|
||||||
|
return "mesh"
|
||||||
|
case .geohash(let geohash):
|
||||||
|
return "geo:\(geohash)"
|
||||||
|
case .direct(let handle):
|
||||||
|
return "direct:\(handle.id.prefix(13))…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Test-only corruption hooks for `auditInvariants()` tests. The store is the
|
||||||
|
// sole writer by design — `Conversation`'s mutators are fileprivate and the
|
||||||
|
// store's backing collections are private — so the inconsistent states the
|
||||||
|
// audit exists to catch CANNOT be manufactured through the intent API. These
|
||||||
|
// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those
|
||||||
|
// impossible states. Never call them outside tests.
|
||||||
|
extension Conversation {
|
||||||
|
/// Points an existing message's index entry at the wrong position
|
||||||
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
|
func _testCorruptIndexEntries() {
|
||||||
|
guard messages.count >= 2 else { return }
|
||||||
|
indexByMessageID[messages[0].id] = 1
|
||||||
|
indexByMessageID[messages[1].id] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
|
func _testRemoveIndexEntry(forMessageID messageID: String) {
|
||||||
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swaps the first and last messages while keeping the index consistent,
|
||||||
|
/// so ONLY the timestamp-order invariant is violated (requires the two
|
||||||
|
/// messages to have distinct timestamps).
|
||||||
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
|
guard messages.count >= 2 else { return }
|
||||||
|
messages.swapAt(0, messages.count - 1)
|
||||||
|
indexByMessageID[messages[0].id] = 0
|
||||||
|
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConversationStore {
|
||||||
|
/// Adds a map membership that the conversation does not actually hold.
|
||||||
|
func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) {
|
||||||
|
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops a real map membership (conversation message missing from map).
|
||||||
|
func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||||
|
conversationIDsByMessageID[messageID]?.remove(id)
|
||||||
|
if conversationIDsByMessageID[messageID]?.isEmpty == true {
|
||||||
|
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends past the conversation cap, bypassing trim (map kept exact so
|
||||||
|
/// only the cap invariant is violated).
|
||||||
|
func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) {
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
conversation._testAppendBypassingTrim(message)
|
||||||
|
conversationIDsByMessageID[message.id, default: []].insert(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a nonexistent conversation unread without creating it.
|
||||||
|
func _testInsertUnreadConversationID(_ id: ConversationID) {
|
||||||
|
unreadConversations.insert(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the selection directly, without `select(_:)`'s create-on-select.
|
||||||
|
func _testSetSelectedConversationID(_ id: ConversationID?) {
|
||||||
|
selectedConversationID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Conversation {
|
||||||
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
|
messages.append(message)
|
||||||
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Public timeline derived views
|
||||||
|
|
||||||
|
extension ConversationStore {
|
||||||
|
/// Removes a message by ID from whichever public (mesh/geohash)
|
||||||
|
/// conversation contains it. Returns the removed message, if any.
|
||||||
|
@discardableResult
|
||||||
|
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||||
|
for id in conversationIDs(forMessageID: messageID) {
|
||||||
|
switch id {
|
||||||
|
case .mesh, .geohash:
|
||||||
|
return removeMessage(withID: messageID, from: id)
|
||||||
|
case .direct:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConversationUIModel: ObservableObject {
|
||||||
|
@Published private(set) var showAutocomplete = false
|
||||||
|
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||||
|
@Published private(set) var currentNickname: String
|
||||||
|
@Published private(set) var isBatchingPublic = false
|
||||||
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
/// Who is talking live in the public mesh channel right now (floor
|
||||||
|
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||||
|
@Published private(set) var activeLiveVoiceTalker: String?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let privateConversationModel: PrivateConversationModel
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private var activeChannel: ChannelID
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateConversationModel: PrivateConversationModel,
|
||||||
|
conversations: ConversationStore
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.privateConversationModel = privateConversationModel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.activeChannel = conversations.activeChannel
|
||||||
|
self.currentNickname = chatViewModel.nickname
|
||||||
|
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||||
|
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||||
|
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||||
|
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
||||||
|
chatViewModel.currentColorScheme = colorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentTheme(_ theme: AppTheme) {
|
||||||
|
chatViewModel.currentTheme = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(_ message: String) {
|
||||||
|
chatViewModel.sendMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resends a failed private message through the normal send path,
|
||||||
|
/// removing the failed original so the re-submission replaces it
|
||||||
|
/// instead of stacking a duplicate under the red bubble.
|
||||||
|
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
||||||
|
chatViewModel.removePrivateMessage(withID: message.id)
|
||||||
|
chatViewModel.sendMessage(message.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCurrentConversation() {
|
||||||
|
chatViewModel.sendMessage("/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHug(to sender: String) {
|
||||||
|
chatViewModel.sendMessage("/hug @\(sender)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSlap(to sender: String) {
|
||||||
|
chatViewModel.sendMessage("/slap @\(sender)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func block(peerID: PeerID?, displayName: String?) {
|
||||||
|
guard let displayName else { return }
|
||||||
|
|
||||||
|
if let peerID, peerID.isGeoChat,
|
||||||
|
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
|
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||||
|
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
||||||
|
// Mesh: block the peer's stable Noise identity resolved from the
|
||||||
|
// tapped peerID rather than re-resolving a display-name string.
|
||||||
|
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
||||||
|
} else {
|
||||||
|
chatViewModel.sendMessage("/block \(displayName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
||||||
|
/// the tapped peer's stable identity so the exact row is unblocked — this
|
||||||
|
/// also works for offline peers, which the `/unblock <displayName>` command
|
||||||
|
/// cannot resolve.
|
||||||
|
func unblock(peerID: PeerID, displayName: String) {
|
||||||
|
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
|
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||||
|
chatViewModel.completeNickname(nickname, in: &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||||
|
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||||
|
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||||
|
message.mediaAttachment(for: currentNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||||
|
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||||
|
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||||
|
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||||
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
|
return chatViewModel.geohashDisplayName(for: peerID)
|
||||||
|
}
|
||||||
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func processSelectedImage(_ image: UIImage?) {
|
||||||
|
chatViewModel.processThenSendImage(image)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func processSelectedImage(from url: URL?) {
|
||||||
|
#if os(macOS)
|
||||||
|
chatViewModel.processThenSendImage(from: url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVoiceNote(at url: URL) {
|
||||||
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||||
|
/// peer can hear it now, classic voice note otherwise.
|
||||||
|
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||||
|
chatViewModel.makeVoiceCaptureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this message is a live voice burst still streaming in.
|
||||||
|
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
|
||||||
|
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMediaSend(messageID: String) {
|
||||||
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
chatViewModel.deleteMediaMessage(messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$currentNickname)
|
||||||
|
|
||||||
|
chatViewModel.$showAutocomplete
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$showAutocomplete)
|
||||||
|
|
||||||
|
chatViewModel.$autocompleteSuggestions
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$autocompleteSuggestions)
|
||||||
|
|
||||||
|
chatViewModel.$isBatchingPublic
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
|
chatViewModel.$activePublicVoiceTalker
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$activeLiveVoiceTalker)
|
||||||
|
|
||||||
|
conversations.$activeChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
self?.activeChannel = channel
|
||||||
|
self?.refreshComputedState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
privateConversationModel.$selectedPeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshComputedState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshComputedState() {
|
||||||
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
|
// Media transfer is not wired for groups in v1; keep it off so the
|
||||||
|
// composer can't strand a media placeholder that never sends.
|
||||||
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
canSendMediaInCurrentContext = true
|
||||||
|
case .location:
|
||||||
|
canSendMediaInCurrentContext = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationChannelsModel: ObservableObject {
|
||||||
|
@Published private(set) var permissionState: LocationChannelManager.PermissionState
|
||||||
|
@Published private(set) var availableChannels: [GeohashChannel]
|
||||||
|
@Published private(set) var selectedChannel: ChannelID
|
||||||
|
@Published private(set) var teleported: Bool
|
||||||
|
@Published private(set) var bookmarks: [String]
|
||||||
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
|
@Published private(set) var userTorEnabled: Bool
|
||||||
|
@Published private(set) var gatewayEnabled: Bool
|
||||||
|
|
||||||
|
private let manager: LocationChannelManager
|
||||||
|
private let network: NetworkActivationService
|
||||||
|
private let gateway: GatewayService
|
||||||
|
|
||||||
|
init(
|
||||||
|
manager: LocationChannelManager? = nil,
|
||||||
|
network: NetworkActivationService? = nil,
|
||||||
|
gateway: GatewayService? = nil
|
||||||
|
) {
|
||||||
|
let manager = manager ?? .shared
|
||||||
|
let network = network ?? .shared
|
||||||
|
let gateway = gateway ?? .shared
|
||||||
|
|
||||||
|
self.manager = manager
|
||||||
|
self.network = network
|
||||||
|
self.gateway = gateway
|
||||||
|
self.gatewayEnabled = gateway.isEnabled
|
||||||
|
self.permissionState = manager.permissionState
|
||||||
|
self.availableChannels = manager.availableChannels
|
||||||
|
self.selectedChannel = manager.selectedChannel
|
||||||
|
self.teleported = manager.teleported
|
||||||
|
self.bookmarks = manager.bookmarks
|
||||||
|
self.bookmarkNames = manager.bookmarkNames
|
||||||
|
self.locationNames = manager.locationNames
|
||||||
|
self.userTorEnabled = network.userTorEnabled
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBuildingGeohash: String? {
|
||||||
|
availableChannels.first(where: { $0.level == .building })?.geohash
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
|
guard case .location(let selected) = selectedChannel else { return false }
|
||||||
|
return selected == channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
|
manager.isBookmarked(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableLocationChannels() {
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannels() {
|
||||||
|
manager.refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableAndRefresh() {
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
manager.refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginLiveRefresh() {
|
||||||
|
manager.beginLiveRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLiveRefresh() {
|
||||||
|
manager.endLiveRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ channel: ChannelID) {
|
||||||
|
manager.select(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
manager.markTeleported(for: geohash, flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleBookmark(_ geohash: String) {
|
||||||
|
manager.toggleBookmark(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||||
|
manager.resolveBookmarkNameIfNeeded(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationName(for level: GeohashChannelLevel) -> String? {
|
||||||
|
locationNames[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
|
network.setUserTorEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMeshChannelsIfNeeded() {
|
||||||
|
guard case .mesh = selectedChannel,
|
||||||
|
permissionState == .authorized,
|
||||||
|
availableChannels.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLocationChannel(for geohash: String) {
|
||||||
|
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
guard (2...12).contains(normalized.count),
|
||||||
|
normalized.allSatisfy({ allowed.contains($0) }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||||
|
let isRegional = availableChannels.contains { $0.geohash == normalized }
|
||||||
|
if !isRegional && !availableChannels.isEmpty {
|
||||||
|
markTeleported(for: normalized, true)
|
||||||
|
}
|
||||||
|
select(.location(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func teleport(to geohash: String) {
|
||||||
|
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||||
|
markTeleported(for: normalized, true)
|
||||||
|
select(.location(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
manager.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$permissionState)
|
||||||
|
|
||||||
|
manager.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$availableChannels)
|
||||||
|
|
||||||
|
manager.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$selectedChannel)
|
||||||
|
|
||||||
|
manager.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$teleported)
|
||||||
|
|
||||||
|
manager.$bookmarks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bookmarks)
|
||||||
|
|
||||||
|
manager.$bookmarkNames
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bookmarkNames)
|
||||||
|
|
||||||
|
manager.$locationNames
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$locationNames)
|
||||||
|
|
||||||
|
network.$userTorEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$userTorEnabled)
|
||||||
|
|
||||||
|
gateway.$isEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$gatewayEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
switch length {
|
||||||
|
case 0...2: return .region
|
||||||
|
case 3...4: return .province
|
||||||
|
case 5: return .city
|
||||||
|
case 6: return .neighborhood
|
||||||
|
case 7: return .block
|
||||||
|
case 8...12: return .building
|
||||||
|
default: return .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationPresenceStore: ObservableObject {
|
||||||
|
@Published private(set) var currentGeohash: String?
|
||||||
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
|
@Published private(set) var teleportedGeo: Set<String> = []
|
||||||
|
|
||||||
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
|
currentGeohash = geohash?.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
|
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
|
geoNicknames = Dictionary(
|
||||||
|
uniqueKeysWithValues: nicknames.map { key, value in
|
||||||
|
(key.lowercased(), value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeoNicknames() {
|
||||||
|
geoNicknames.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markTeleported(_ pubkeyHex: String) {
|
||||||
|
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
|
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
|
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleportedGeo() {
|
||||||
|
teleportedGeo.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
currentGeohash = nil
|
||||||
|
geoNicknames.removeAll()
|
||||||
|
teleportedGeo.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
private var manager: LocationNotesManager?
|
||||||
|
private var managerCancellable: AnyCancellable?
|
||||||
|
private var channelsCancellable: AnyCancellable?
|
||||||
|
private var settingCancellable: AnyCancellable?
|
||||||
|
private var activeHolders = 0
|
||||||
|
private let locationManager: LocationChannelManager
|
||||||
|
|
||||||
|
init(locationManager: LocationChannelManager = .shared) {
|
||||||
|
self.locationManager = locationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() }
|
||||||
|
// 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
|
||||||
|
settingCancellable = nil
|
||||||
|
managerCancellable = nil
|
||||||
|
manager?.cancel()
|
||||||
|
manager = nil
|
||||||
|
noteCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retarget() {
|
||||||
|
guard activeHolders > 0,
|
||||||
|
LocationNotesSettings.enabled,
|
||||||
|
locationManager.permissionState == .authorized,
|
||||||
|
let geohash = locationManager.availableChannels
|
||||||
|
.first(where: { $0.level == .building })?.geohash
|
||||||
|
else {
|
||||||
|
managerCancellable = nil
|
||||||
|
manager?.cancel()
|
||||||
|
manager = nil
|
||||||
|
noteCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let manager {
|
||||||
|
manager.setGeohash(geohash)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fresh = LocationNotesManager(geohash: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeerIdentityStore: ObservableObject {
|
||||||
|
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
|
||||||
|
@Published private(set) var verifiedFingerprints: Set<String> = []
|
||||||
|
|
||||||
|
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
|
||||||
|
private(set) var selectedPrivateChatFingerprint: String?
|
||||||
|
|
||||||
|
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
|
||||||
|
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||||||
|
|
||||||
|
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
|
||||||
|
stablePeerIDsByShortID[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
|
||||||
|
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
|
||||||
|
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peerFingerprintsByPeerID[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
|
||||||
|
if let fingerprint {
|
||||||
|
peerFingerprintsByPeerID[peerID] = fingerprint
|
||||||
|
} else {
|
||||||
|
peerFingerprintsByPeerID.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
|
||||||
|
peerFingerprintsByPeerID = mappings
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func migrateFingerprintMapping(
|
||||||
|
from oldPeerID: PeerID,
|
||||||
|
to newPeerID: PeerID,
|
||||||
|
fallback: String? = nil
|
||||||
|
) -> String? {
|
||||||
|
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
|
||||||
|
if let fingerprint {
|
||||||
|
peerFingerprintsByPeerID[newPeerID] = fingerprint
|
||||||
|
if selectedPrivateChatFingerprint == nil {
|
||||||
|
selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
|
||||||
|
selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||||
|
encryptionStatusCache[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
|
||||||
|
encryptionStatusCache[peerID] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||||
|
if let peerID {
|
||||||
|
encryptionStatusCache.removeValue(forKey: peerID)
|
||||||
|
} else {
|
||||||
|
encryptionStatusCache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||||
|
encryptionStatuses[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
|
||||||
|
if let status {
|
||||||
|
encryptionStatuses[peerID] = status
|
||||||
|
} else {
|
||||||
|
encryptionStatuses.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
invalidateEncryptionCache(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||||
|
verifiedFingerprints = fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVerified(_ fingerprint: String, verified: Bool) {
|
||||||
|
if verified {
|
||||||
|
verifiedFingerprints.insert(fingerprint)
|
||||||
|
} else {
|
||||||
|
verifiedFingerprints.remove(fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVerified(_ fingerprint: String) -> Bool {
|
||||||
|
verifiedFingerprints.contains(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
encryptionStatuses.removeAll()
|
||||||
|
verifiedFingerprints.removeAll()
|
||||||
|
peerFingerprintsByPeerID.removeAll()
|
||||||
|
selectedPrivateChatFingerprint = nil
|
||||||
|
stablePeerIDsByShortID.removeAll()
|
||||||
|
encryptionStatusCache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MeshPeerRow: Identifiable, Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let displayName: String
|
||||||
|
let isMe: Bool
|
||||||
|
let hasUnread: Bool
|
||||||
|
let isBlocked: Bool
|
||||||
|
let isFavorite: Bool
|
||||||
|
let isConnected: Bool
|
||||||
|
let isReachable: Bool
|
||||||
|
let isMutualFavorite: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let showsVerifiedBadgeWhenOffline: Bool
|
||||||
|
/// Vouched-for by someone I verified, without an explicit verification of
|
||||||
|
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
||||||
|
let showsVouchedBadge: Bool
|
||||||
|
|
||||||
|
var id: String { peerID.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GeohashPersonRow: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let displayName: String
|
||||||
|
let isMe: Bool
|
||||||
|
let isTeleported: Bool
|
||||||
|
let isBlocked: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupChatRow: Identifiable, Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let name: String
|
||||||
|
let memberCount: Int
|
||||||
|
let isCreator: Bool
|
||||||
|
let hasUnread: Bool
|
||||||
|
|
||||||
|
var id: String { peerID.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeerListModel: ObservableObject {
|
||||||
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
|
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||||
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
|
@Published private(set) var renderID = ""
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private let locationPresenceStore: LocationPresenceStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
conversations: ConversationStore,
|
||||||
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
|
locationPresenceStore: LocationPresenceStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||||
|
self.allPeers = chatViewModel.allPeers
|
||||||
|
|
||||||
|
bind()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||||
|
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
||||||
|
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func participantCount(for geohash: String) -> Int {
|
||||||
|
chatViewModel.geohashParticipantCount(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConversation(with peerID: PeerID) {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
chatViewModel.toggleFavorite(peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||||
|
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
chatViewModel.blockGeohashUser(
|
||||||
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
chatViewModel.unblockGeohashUser(
|
||||||
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] peers in
|
||||||
|
self?.allPeers = peers
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationPresenceStore.$teleportedGeo
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversations.$unreadConversations
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.groupStore.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$verifiedFingerprints
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.participantTracker.$visiblePeople
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() {
|
||||||
|
let myPeerID = chatViewModel.meshService.myPeerID
|
||||||
|
let meshRows = allPeers.map { peer in
|
||||||
|
let isMe = peer.peerID == myPeerID
|
||||||
|
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||||
|
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
|
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||||
|
// Vouched is subordinate to verified: never show both seals.
|
||||||
|
let vouchedBadge = !isVerifiedFingerprint
|
||||||
|
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||||
|
|
||||||
|
return MeshPeerRow(
|
||||||
|
peerID: peer.peerID,
|
||||||
|
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||||
|
isMe: isMe,
|
||||||
|
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||||
|
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||||
|
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||||
|
isConnected: peer.isConnected,
|
||||||
|
isReachable: peer.isReachable,
|
||||||
|
isMutualFavorite: peer.isMutualFavorite,
|
||||||
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||||
|
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||||
|
showsVouchedBadge: vouchedBadge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
||||||
|
guard !row.isMe else { return }
|
||||||
|
if row.isConnected {
|
||||||
|
counts.connected += 1
|
||||||
|
counts.reachable += 1
|
||||||
|
} else if row.isReachable {
|
||||||
|
counts.reachable += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let geohashPeople = buildGeohashPeople()
|
||||||
|
let groupRows = buildGroupRows()
|
||||||
|
|
||||||
|
self.meshRows = meshRows
|
||||||
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
|
self.geohashPeople = geohashPeople
|
||||||
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
|
self.groupRows = groupRows
|
||||||
|
renderID = (
|
||||||
|
meshRows.map {
|
||||||
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
|
} +
|
||||||
|
geohashPeople.map {
|
||||||
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
|
} +
|
||||||
|
groupRows.map {
|
||||||
|
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||||
|
}
|
||||||
|
).joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGroupRows() -> [GroupChatRow] {
|
||||||
|
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||||
|
return chatViewModel.groupStore.groups.map { group in
|
||||||
|
GroupChatRow(
|
||||||
|
peerID: group.peerID,
|
||||||
|
name: group.name,
|
||||||
|
memberCount: group.members.count,
|
||||||
|
isCreator: group.creatorFingerprint == myFingerprint,
|
||||||
|
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
|
let myHex = currentGeohashIdentityHex()
|
||||||
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|
||||||
|
return chatViewModel.visibleGeohashPeople().map { person in
|
||||||
|
let isMe = person.id == myHex
|
||||||
|
return GeohashPersonRow(
|
||||||
|
id: person.id,
|
||||||
|
displayName: person.displayName,
|
||||||
|
isMe: isMe,
|
||||||
|
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
||||||
|
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentGeohashIdentityHex() -> String? {
|
||||||
|
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||||
|
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity.publicKeyHex.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Feature model for private (direct) conversations.
|
||||||
|
///
|
||||||
|
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
|
||||||
|
/// returns the peer's conversation backing array (no mirror dictionary), and
|
||||||
|
/// the store's typed `changes` subject drives invalidation — a change in the
|
||||||
|
/// SELECTED peer's conversation republishes this model, while appends to
|
||||||
|
/// other private chats only surface through the unread set. Direct
|
||||||
|
/// conversations are keyed by raw routing peer ID; the coordinators'
|
||||||
|
/// ephemeral/stable mirroring guarantees the selected peer's key always
|
||||||
|
/// holds the full timeline (see `ConversationID.directPeer`).
|
||||||
|
@MainActor
|
||||||
|
final class PrivateInboxModel: ObservableObject {
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||||
|
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(conversations: ConversationStore) {
|
||||||
|
self.conversations = conversations
|
||||||
|
self.selectedPeerID = conversations.selectedPrivatePeerID
|
||||||
|
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
|
guard let peerID else { return [] }
|
||||||
|
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversations.$selectedPrivatePeerID
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] peerID in
|
||||||
|
guard let self, self.selectedPeerID != peerID else { return }
|
||||||
|
self.selectedPeerID = peerID
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversations.changes
|
||||||
|
.sink { [weak self] change in
|
||||||
|
self?.apply(change)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ change: ConversationChange) {
|
||||||
|
switch change {
|
||||||
|
case .appended(let id, _),
|
||||||
|
.updated(let id, _),
|
||||||
|
.statusChanged(let id, _, _),
|
||||||
|
.messageRemoved(let id, _),
|
||||||
|
.cleared(let id):
|
||||||
|
republishIfSelected(id)
|
||||||
|
|
||||||
|
case .unreadChanged(let id, _):
|
||||||
|
guard isDirect(id) else { return }
|
||||||
|
refreshUnreadPeerIDs()
|
||||||
|
|
||||||
|
case .removed(let id):
|
||||||
|
guard isDirect(id) else { return }
|
||||||
|
refreshUnreadPeerIDs()
|
||||||
|
republishIfSelected(id)
|
||||||
|
|
||||||
|
case .migrated(let source, let destination):
|
||||||
|
guard isDirect(source) || isDirect(destination) else { return }
|
||||||
|
refreshUnreadPeerIDs()
|
||||||
|
republishIfSelected(source)
|
||||||
|
republishIfSelected(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func republishIfSelected(_ id: ConversationID) {
|
||||||
|
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshUnreadPeerIDs() {
|
||||||
|
let next = conversations.unreadDirectRoutingPeerIDs()
|
||||||
|
guard unreadPeerIDs != next else { return }
|
||||||
|
unreadPeerIDs = next
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isDirect(_ id: ConversationID) -> Bool {
|
||||||
|
if case .direct = id { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PrivateConversationAvailability: Equatable {
|
||||||
|
case bluetoothConnected
|
||||||
|
case meshReachable
|
||||||
|
case nostrAvailable
|
||||||
|
case offline
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivateConversationHeaderState: Equatable {
|
||||||
|
let conversationPeerID: PeerID
|
||||||
|
let headerPeerID: PeerID
|
||||||
|
let displayName: String
|
||||||
|
let availability: PrivateConversationAvailability
|
||||||
|
let isFavorite: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
|
var supportsFavoriteToggle: Bool {
|
||||||
|
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group chats have no single peer identity behind the header: no
|
||||||
|
/// fingerprint screen, no per-peer encryption badge.
|
||||||
|
var isGroupConversation: Bool {
|
||||||
|
conversationPeerID.isGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PrivateConversationModel: ObservableObject {
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
conversations: ConversationStore,
|
||||||
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
let initialPeerID = conversations.selectedPrivatePeerID
|
||||||
|
self.selectedPeerID = initialPeerID
|
||||||
|
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||||
|
makeHeaderState(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConversation(with peerID: PeerID) {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openConversation(for peerID: PeerID) {
|
||||||
|
if peerID.isGeoChat {
|
||||||
|
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
||||||
|
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
|
} else {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endConversation() {
|
||||||
|
chatViewModel.endPrivateChat()
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
chatViewModel.toggleFavorite(peerID: peerID)
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavoriteForSelectedConversation() {
|
||||||
|
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
||||||
|
toggleFavorite(peerID: headerPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMessagesAsRead(from peerID: PeerID) {
|
||||||
|
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversations.$selectedPrivatePeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.groupStore.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSelectedConversation() {
|
||||||
|
selectedPeerID = conversations.selectedPrivatePeerID
|
||||||
|
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||||
|
makeHeaderState(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||||
|
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||||
|
// the header; availability reads as mesh since group traffic floods
|
||||||
|
// the local mesh, and the per-peer encryption badge does not apply.
|
||||||
|
if conversationPeerID.isGroup {
|
||||||
|
let displayName: String
|
||||||
|
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||||
|
displayName = "#\(group.name) (\(group.members.count))"
|
||||||
|
} else {
|
||||||
|
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||||
|
}
|
||||||
|
return PrivateConversationHeaderState(
|
||||||
|
conversationPeerID: conversationPeerID,
|
||||||
|
headerPeerID: conversationPeerID,
|
||||||
|
displayName: displayName,
|
||||||
|
availability: .meshReachable,
|
||||||
|
isFavorite: false,
|
||||||
|
encryptionStatus: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
|
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||||
|
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||||
|
// report .offline. Report .nostrAvailable so the header shows the
|
||||||
|
// globe instead of a misleading "offline" tag.
|
||||||
|
let availability = conversationPeerID.isGeoDM
|
||||||
|
? .nostrAvailable
|
||||||
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||||
|
? nil
|
||||||
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
|
|
||||||
|
return PrivateConversationHeaderState(
|
||||||
|
conversationPeerID: conversationPeerID,
|
||||||
|
headerPeerID: headerPeerID,
|
||||||
|
displayName: displayName,
|
||||||
|
availability: availability,
|
||||||
|
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
||||||
|
encryptionStatus: encryptionStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveDisplayName(
|
||||||
|
for conversationPeerID: PeerID,
|
||||||
|
headerPeerID: PeerID,
|
||||||
|
peer: BitchatPeer?
|
||||||
|
) -> String {
|
||||||
|
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
|
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||||
|
}
|
||||||
|
if let displayName = peer?.displayName {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
||||||
|
for: Data(hexString: headerPeerID.id) ?? Data()
|
||||||
|
), !favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
if headerPeerID.id.count == 16 {
|
||||||
|
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||||
|
if let identity = candidates.first,
|
||||||
|
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let noiseKey = headerPeerID.noiseKey {
|
||||||
|
let fingerprint = noiseKey.sha256Fingerprint()
|
||||||
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||||
|
if let connectionState = peer?.connectionState {
|
||||||
|
switch connectionState {
|
||||||
|
case .bluetoothConnected:
|
||||||
|
return .bluetoothConnected
|
||||||
|
case .meshReachable:
|
||||||
|
return .meshReachable
|
||||||
|
case .nostrAvailable:
|
||||||
|
return .nostrAvailable
|
||||||
|
case .offline:
|
||||||
|
return .offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
||||||
|
return .meshReachable
|
||||||
|
}
|
||||||
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||||
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
favoriteStatus.isMutual {
|
||||||
|
return .nostrAvailable
|
||||||
|
}
|
||||||
|
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||||
|
return .bluetoothConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return .offline
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Feature model for the active public (mesh/geohash) timeline.
|
||||||
|
///
|
||||||
|
/// Observes ONE `Conversation` object in the single-writer
|
||||||
|
/// `ConversationStore` — the active channel's — so appends to background
|
||||||
|
/// conversations (other geohashes, private chats) never invalidate it.
|
||||||
|
/// `messages` reads the observed conversation's backing array directly;
|
||||||
|
/// there is no mirror copy.
|
||||||
|
@MainActor
|
||||||
|
final class PublicChatModel: ObservableObject {
|
||||||
|
@Published private(set) var activeChannel: ChannelID
|
||||||
|
|
||||||
|
/// The active public conversation's timeline.
|
||||||
|
var messages: [BitchatMessage] { activeConversation.messages }
|
||||||
|
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private var activeConversation: Conversation
|
||||||
|
private var activeConversationCancellable: AnyCancellable?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(conversations: ConversationStore) {
|
||||||
|
let channel = conversations.activeChannel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.activeChannel = channel
|
||||||
|
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||||
|
|
||||||
|
observeActiveConversation()
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversations.$activeChannel
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
guard let self else { return }
|
||||||
|
self.activeChannel = channel
|
||||||
|
self.retargetActiveConversation(to: channel)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// The store replaces a conversation's object when it is removed
|
||||||
|
// (panic clear); retarget to the fresh instance so the observation
|
||||||
|
// never goes stale.
|
||||||
|
conversations.changes
|
||||||
|
.sink { [weak self] change in
|
||||||
|
guard let self,
|
||||||
|
case .removed(let id) = change,
|
||||||
|
id == self.activeConversation.id else { return }
|
||||||
|
self.retargetActiveConversation(to: self.activeChannel)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retargetActiveConversation(to channel: ChannelID) {
|
||||||
|
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||||
|
guard conversation !== activeConversation else {
|
||||||
|
// Same object (e.g. re-selected channel): keep the existing
|
||||||
|
// observation, but `messages` may still differ from what views
|
||||||
|
// last rendered, so republish.
|
||||||
|
objectWillChange.send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
objectWillChange.send()
|
||||||
|
activeConversation = conversation
|
||||||
|
observeActiveConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeActiveConversation() {
|
||||||
|
activeConversationCancellable = activeConversation.objectWillChange
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FingerprintPresentationState: Equatable {
|
||||||
|
let peerNickname: String
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let theirFingerprint: String?
|
||||||
|
let myFingerprint: String
|
||||||
|
let isVerified: Bool
|
||||||
|
/// Number of currently-valid vouches from peers the user verified
|
||||||
|
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
||||||
|
let voucherCount: Int
|
||||||
|
/// Display names of the (verified) vouchers, where known.
|
||||||
|
let voucherNames: [String]
|
||||||
|
|
||||||
|
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
|
||||||
|
var isVouched: Bool { voucherCount > 0 }
|
||||||
|
|
||||||
|
var canToggleVerification: Bool {
|
||||||
|
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationScanOutcome: Equatable {
|
||||||
|
case requested(String)
|
||||||
|
case notFound
|
||||||
|
case invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class VerificationModel: ObservableObject {
|
||||||
|
@Published private(set) var currentNickname: String
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateConversationModel: PrivateConversationModel,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
self.currentNickname = chatViewModel.nickname
|
||||||
|
self.selectedPeerID = privateConversationModel.selectedPeerID
|
||||||
|
|
||||||
|
bind(privateConversationModel: privateConversationModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func myQRString() -> String {
|
||||||
|
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||||
|
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||||
|
return .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
guard chatViewModel.beginQRVerification(with: qr) else {
|
||||||
|
return .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return .requested(qr.nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyFingerprint(for peerID: PeerID) {
|
||||||
|
chatViewModel.verifyFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unverifyFingerprint(for peerID: PeerID) {
|
||||||
|
chatViewModel.unverifyFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVerified(peerID: PeerID) -> Bool {
|
||||||
|
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||||
|
return peerIdentityStore.isVerified(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
|
||||||
|
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||||
|
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||||
|
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||||
|
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
|
|
||||||
|
// Vouch state is recomputed on read: only vouchers still in the
|
||||||
|
// verified set count, so removing a verification silently retires the
|
||||||
|
// vouches that peer gave.
|
||||||
|
let vouchers: [VouchRecord]
|
||||||
|
if !isVerified, let theirFingerprint {
|
||||||
|
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
|
||||||
|
} else {
|
||||||
|
vouchers = []
|
||||||
|
}
|
||||||
|
let voucherNames = vouchers.compactMap { record -> String? in
|
||||||
|
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let petname = social.localPetname, !petname.isEmpty { return petname }
|
||||||
|
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
|
||||||
|
}
|
||||||
|
|
||||||
|
return FingerprintPresentationState(
|
||||||
|
peerNickname: peerNickname,
|
||||||
|
encryptionStatus: encryptionStatus,
|
||||||
|
theirFingerprint: theirFingerprint,
|
||||||
|
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||||
|
isVerified: isVerified,
|
||||||
|
voucherCount: vouchers.count,
|
||||||
|
voucherNames: voucherNames
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(privateConversationModel: PrivateConversationModel) {
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$currentNickname)
|
||||||
|
|
||||||
|
privateConversationModel.$selectedPeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$selectedPeerID)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$verifiedFingerprints
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
|
||||||
|
// are signalled via this notification rather than a published
|
||||||
|
// property, so an open fingerprint sheet refreshes its vouched badge
|
||||||
|
// live when a vouch batch is accepted.
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||||
|
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||||
|
return peer.displayName
|
||||||
|
}
|
||||||
|
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if let data = peerID.noiseKey {
|
||||||
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
|
||||||
|
!favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
let fingerprint = data.sha256Fingerprint()
|
||||||
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 460 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 50 KiB |
@@ -27,6 +27,66 @@
|
|||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_16x16.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_16x16@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_32x32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_32x32@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_128x128.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_128x128@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_256x256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_256x256@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_512x512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "mac_512x512@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Tor
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -14,115 +13,51 @@ import UserNotifications
|
|||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
static let groupID = "group.\(bundleID)"
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
@StateObject private var runtime: AppRuntime
|
||||||
|
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
// Skip the very first .active-triggered Tor restart on cold launch
|
|
||||||
@State private var didHandleInitialActive: Bool = false
|
|
||||||
@State private var didEnterBackground: Bool = false
|
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||||
let idBridge = self.idBridge
|
|
||||||
_chatViewModel = StateObject(
|
|
||||||
wrappedValue: ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
// Warm up georelay directory and refresh if stale (once/day)
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(chatViewModel)
|
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
|
||||||
|
.environmentObject(runtime.publicChatModel)
|
||||||
|
.environmentObject(runtime.privateInboxModel)
|
||||||
|
.environmentObject(runtime.privateConversationModel)
|
||||||
|
.environmentObject(runtime.verificationModel)
|
||||||
|
.environmentObject(runtime.conversationUIModel)
|
||||||
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
|
.environmentObject(runtime.peerListModel)
|
||||||
|
.environmentObject(runtime.appChromeModel)
|
||||||
|
.environmentObject(runtime.boardAlertsModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
appDelegate.runtime = runtime
|
||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
runtime.start()
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
|
||||||
let nickname = chatViewModel.nickname
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
|
||||||
}
|
|
||||||
|
|
||||||
appDelegate.chatViewModel = chatViewModel
|
|
||||||
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
|
||||||
NetworkActivationService.shared.start()
|
|
||||||
// Check for shared content
|
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleURL(url)
|
runtime.handleOpenURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
switch newPhase {
|
runtime.handleScenePhaseChange(newPhase)
|
||||||
case .background:
|
|
||||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
|
||||||
// Always send Tor to dormant on background for a clean restart later.
|
|
||||||
TorManager.shared.setAppForeground(false)
|
|
||||||
TorManager.shared.goDormantOnBackground()
|
|
||||||
// Stop geohash sampling while backgrounded
|
|
||||||
Task { @MainActor in
|
|
||||||
chatViewModel.endGeohashSampling()
|
|
||||||
}
|
|
||||||
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
|
||||||
NostrRelayManager.shared.disconnect()
|
|
||||||
didEnterBackground = true
|
|
||||||
case .active:
|
|
||||||
// Restart services when becoming active
|
|
||||||
chatViewModel.meshService.startServices()
|
|
||||||
TorManager.shared.setAppForeground(true)
|
|
||||||
// On initial cold launch, Tor was just started in onAppear.
|
|
||||||
// Skip the deterministic restart the first time we become active.
|
|
||||||
if didHandleInitialActive && didEnterBackground {
|
|
||||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
didHandleInitialActive = true
|
|
||||||
}
|
|
||||||
didEnterBackground = false
|
|
||||||
if TorManager.shared.isAutoStartAllowed() {
|
|
||||||
Task.detached {
|
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
|
||||||
await MainActor.run {
|
|
||||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
|
||||||
TorURLSession.shared.rebuild()
|
|
||||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
|
||||||
NostrRelayManager.shared.resetAllConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkForSharedContent()
|
|
||||||
case .inactive:
|
|
||||||
break
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
// Check for shared content when app becomes active
|
runtime.handleDidBecomeActiveNotification()
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
// App became active
|
runtime.handleMacDidBecomeActiveNotification()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -131,66 +66,18 @@ struct BitchatApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleURL(_ url: URL) {
|
|
||||||
if url.scheme == "bitchat" && url.host == "share" {
|
|
||||||
// Handle shared content
|
|
||||||
checkForSharedContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkForSharedContent() {
|
|
||||||
// Check app group for shared content from extension
|
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
|
||||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process if shared within configured window
|
|
||||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
|
||||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
|
||||||
|
|
||||||
// Clear the shared content
|
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
// No need to force synchronize here
|
|
||||||
|
|
||||||
// Send the shared content immediately on the main queue
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if contentType == "url" {
|
|
||||||
// Try to parse as JSON first
|
|
||||||
if let data = sharedContent.data(using: .utf8),
|
|
||||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
|
||||||
let url = urlData["url"] {
|
|
||||||
// Send plain URL
|
|
||||||
self.chatViewModel.sendMessage(url)
|
|
||||||
} else {
|
|
||||||
// Fallback to simple URL
|
|
||||||
self.chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -199,84 +86,50 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
|
let actionIdentifier = response.actionIdentifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
// Complete only after the response is handled: for a background
|
||||||
if identifier.hasPrefix("private-") {
|
// action (👋 wave) the system may suspend the app the moment the
|
||||||
// Get peer ID from userInfo
|
// completion handler runs, which would drop the queued send.
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
Task { @MainActor in
|
||||||
DispatchQueue.main.async {
|
self.runtime?.handleNotificationResponse(
|
||||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
identifier: identifier,
|
||||||
}
|
actionIdentifier: actionIdentifier,
|
||||||
}
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
completionHandler()
|
||||||
}
|
}
|
||||||
// Handle deeplink (e.g., geohash activity)
|
|
||||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
|
||||||
#if os(iOS)
|
|
||||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
|
||||||
#else
|
|
||||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
|
||||||
if identifier.hasPrefix("private-") {
|
|
||||||
// Get peer ID from userInfo
|
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
|
||||||
// Don't show notification if the private chat is already open
|
|
||||||
// Access main-actor-isolated property via Task
|
|
||||||
Task { @MainActor in
|
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
|
||||||
let deep = userInfo["deeplink"] as? String,
|
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String {
|
Task {
|
||||||
var nilIfEmpty: String? {
|
let options = await self.runtime?.presentationOptions(
|
||||||
self.isEmpty ? nil : self
|
forNotificationIdentifier: identifier,
|
||||||
|
userInfo: userInfo
|
||||||
|
) ?? [.banner, .sound]
|
||||||
|
completionHandler(options)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,30 +15,39 @@ enum ImageUtilsError: Error {
|
|||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
try validateImageSource(at: url)
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
// Allow up to 10MB source images (will be scaled down)
|
|
||||||
guard fileSize <= 10 * 1024 * 1024 else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#else
|
#else
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func validateImageSource(at url: URL) throws {
|
||||||
|
// Security H1: Check file size BEFORE reading into memory.
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attrs[.size] as? Int,
|
||||||
|
fileSize > 0,
|
||||||
|
fileSize <= maxSourceImageBytes else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
|
||||||
|
CGImageSourceGetType(source) != nil else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -64,7 +73,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -106,7 +115,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
@@ -142,7 +151,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -186,12 +195,17 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL() throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||||
|
|
||||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
let directory: URL
|
||||||
|
if let outputDirectory {
|
||||||
|
directory = outputDirectory
|
||||||
|
} else {
|
||||||
|
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
}
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return directory.appendingPathComponent(fileName)
|
return directory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// PTTAudioCodec.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
|
||||||
|
/// carries a bit reservoir across frames); one instance per burst.
|
||||||
|
/// Not thread-safe — confine to one queue.
|
||||||
|
final class PTTFrameEncoder {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private var pendingInput: [AVAudioPCMBuffer] = []
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let aac = PTTAudioFormat.aacFormat,
|
||||||
|
let converter = AVAudioConverter(from: pcm, to: aac)
|
||||||
|
else { return nil }
|
||||||
|
converter.bitRate = PTTAudioFormat.bitRate
|
||||||
|
self.converter = converter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
|
||||||
|
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
|
||||||
|
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
|
||||||
|
pendingInput.append(buffer)
|
||||||
|
return drainConverter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainConverter() -> [Data] {
|
||||||
|
var frames: [Data] = []
|
||||||
|
while true {
|
||||||
|
let output = AVAudioCompressedBuffer(
|
||||||
|
format: converter.outputFormat,
|
||||||
|
packetCapacity: 8,
|
||||||
|
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
|
||||||
|
)
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
|
||||||
|
guard let self, let next = self.pendingInput.first else {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.pendingInput.removeFirst()
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
if status == .error {
|
||||||
|
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
frames.append(contentsOf: Self.extractPackets(from: output))
|
||||||
|
// .haveData means the output buffer filled and more may be ready;
|
||||||
|
// anything else means the converter wants more input.
|
||||||
|
if status != .haveData { return frames }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
|
||||||
|
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
|
||||||
|
var frames: [Data] = []
|
||||||
|
frames.reserveCapacity(Int(buffer.packetCount))
|
||||||
|
for index in 0..<Int(buffer.packetCount) {
|
||||||
|
let description = descriptions[index]
|
||||||
|
guard description.mDataByteSize > 0 else { continue }
|
||||||
|
let start = buffer.data.advanced(by: Int(description.mStartOffset))
|
||||||
|
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
|
||||||
|
/// inbound burst. Not thread-safe — confine to one queue/actor.
|
||||||
|
final class PTTFrameDecoder {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private let pcmFormat: AVAudioFormat
|
||||||
|
private let aacFormat: AVAudioFormat
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let aac = PTTAudioFormat.aacFormat,
|
||||||
|
let converter = AVAudioConverter(from: aac, to: pcm)
|
||||||
|
else { return nil }
|
||||||
|
self.converter = converter
|
||||||
|
self.pcmFormat = pcm
|
||||||
|
self.aacFormat = aac
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
|
||||||
|
/// while the decoder is still priming (the first frame of a stream).
|
||||||
|
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
|
||||||
|
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
|
||||||
|
|
||||||
|
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
|
||||||
|
frame.withUnsafeBytes { raw in
|
||||||
|
guard let base = raw.baseAddress else { return }
|
||||||
|
input.data.copyMemory(from: base, byteCount: frame.count)
|
||||||
|
}
|
||||||
|
input.byteLength = UInt32(frame.count)
|
||||||
|
input.packetCount = 1
|
||||||
|
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
||||||
|
mStartOffset: 0,
|
||||||
|
mVariableFramesInPacket: 0,
|
||||||
|
mDataByteSize: UInt32(frame.count)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let output = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: pcmFormat,
|
||||||
|
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
var consumed = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if consumed {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
guard status != .error else {
|
||||||
|
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output.frameLength > 0 ? output : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample-rate/channel converter from the microphone's native format to the
|
||||||
|
/// 16 kHz mono processing format. Stateful; not thread-safe.
|
||||||
|
final class PTTInputResampler {
|
||||||
|
private let converter: AVAudioConverter
|
||||||
|
private let outputFormat: AVAudioFormat
|
||||||
|
private let ratio: Double
|
||||||
|
|
||||||
|
init?(inputFormat: AVAudioFormat) {
|
||||||
|
guard let pcm = PTTAudioFormat.pcmFormat,
|
||||||
|
let converter = AVAudioConverter(from: inputFormat, to: pcm)
|
||||||
|
else { return nil }
|
||||||
|
self.converter = converter
|
||||||
|
self.outputFormat = pcm
|
||||||
|
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||||
|
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
||||||
|
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
|
||||||
|
|
||||||
|
var consumed = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if consumed {
|
||||||
|
outStatus.pointee = .noDataNow
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
consumed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
guard status != .error else {
|
||||||
|
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return output.frameLength > 0 ? output : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// PTTAudioFormat.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
|
||||||
|
/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings
|
||||||
|
/// so a burst's finalized `.m4a` and its live frames sound the same.
|
||||||
|
enum PTTAudioFormat {
|
||||||
|
static let sampleRate: Double = 16_000
|
||||||
|
static let channelCount: AVAudioChannelCount = 1
|
||||||
|
static let bitRate = 16_000
|
||||||
|
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
|
||||||
|
static let samplesPerFrame: AVAudioFrameCount = 1024
|
||||||
|
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
|
||||||
|
|
||||||
|
/// Uncompressed processing format (deinterleaved float PCM).
|
||||||
|
static var pcmFormat: AVAudioFormat? {
|
||||||
|
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compressed wire format.
|
||||||
|
static var aacFormat: AVAudioFormat? {
|
||||||
|
var description = AudioStreamBasicDescription(
|
||||||
|
mSampleRate: sampleRate,
|
||||||
|
mFormatID: kAudioFormatMPEG4AAC,
|
||||||
|
mFormatFlags: 0,
|
||||||
|
mBytesPerPacket: 0,
|
||||||
|
mFramesPerPacket: samplesPerFrame,
|
||||||
|
mBytesPerFrame: 0,
|
||||||
|
mChannelsPerFrame: channelCount,
|
||||||
|
mBitsPerChannel: 0,
|
||||||
|
mReserved: 0
|
||||||
|
)
|
||||||
|
return AVAudioFormat(streamDescription: &description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice-note container settings for the finalized `.m4a`, mirroring
|
||||||
|
/// `VoiceRecorder.startRecording()`.
|
||||||
|
static var voiceNoteFileSettings: [String: Any] {
|
||||||
|
[
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: sampleRate,
|
||||||
|
AVNumberOfChannelsKey: Int(channelCount),
|
||||||
|
AVEncoderBitRateKey: bitRate
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
|
||||||
|
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
|
||||||
|
/// stream is playable at any prefix — a partially received burst is still a
|
||||||
|
/// replayable voice note.
|
||||||
|
enum ADTSFramer {
|
||||||
|
private static let headerSize = 7
|
||||||
|
/// MPEG-4 sampling frequency index for 16 kHz.
|
||||||
|
private static let samplingFrequencyIndex: UInt8 = 8
|
||||||
|
private static let channelConfiguration: UInt8 = 1
|
||||||
|
|
||||||
|
/// Wraps one raw AAC-LC frame in an ADTS header.
|
||||||
|
static func frame(_ aacFrame: Data) -> Data {
|
||||||
|
let frameLength = aacFrame.count + headerSize
|
||||||
|
var data = Data(capacity: frameLength)
|
||||||
|
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
|
||||||
|
data.append(0xFF)
|
||||||
|
data.append(0xF1)
|
||||||
|
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
|
||||||
|
// private bit 0, channel config high bit.
|
||||||
|
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
|
||||||
|
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
|
||||||
|
data.append(UInt8((frameLength >> 3) & 0xFF))
|
||||||
|
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
|
||||||
|
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
|
||||||
|
data.append(0xFC)
|
||||||
|
data.append(aacFrame)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// PTTBurstPlayer.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
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
@MainActor
|
||||||
|
final class PTTBurstPlayer {
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private let node = AVAudioPlayerNode()
|
||||||
|
private let decoder: PTTFrameDecoder
|
||||||
|
|
||||||
|
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
||||||
|
private var queuedDuration: TimeInterval = 0
|
||||||
|
private var scheduledCount = 0
|
||||||
|
private var engineStarted = false
|
||||||
|
private var finished = false
|
||||||
|
private var stopped = false
|
||||||
|
private var deadlineTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private(set) var isPlaying = false
|
||||||
|
|
||||||
|
init?() {
|
||||||
|
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||||
|
self.decoder = decoder
|
||||||
|
engine.attach(node)
|
||||||
|
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||||
|
|
||||||
|
deadlineTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
||||||
|
self?.startIfReady(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
stopIfDrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immediate stop (cancel, another playback taking over, teardown).
|
||||||
|
func stop() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
stopped = true
|
||||||
|
deadlineTask?.cancel()
|
||||||
|
queuedBuffers = []
|
||||||
|
if engineStarted {
|
||||||
|
node.stop()
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startIfReady(force: Bool) {
|
||||||
|
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
|
||||||
|
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
engine.prepare()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||||
|
stopped = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
engineStarted = true
|
||||||
|
isPlaying = true
|
||||||
|
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||||
|
node.play()
|
||||||
|
|
||||||
|
let buffered = queuedBuffers
|
||||||
|
queuedBuffers = []
|
||||||
|
queuedDuration = 0
|
||||||
|
for buffer in buffered {
|
||||||
|
schedule(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||||
|
scheduledCount += 1
|
||||||
|
node.scheduleBuffer(buffer) { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scheduledCount -= 1
|
||||||
|
self.stopIfDrained()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopIfDrained() {
|
||||||
|
guard finished, scheduledCount <= 0 else { return }
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PTTBurstPlayer: ExclusivePlayback {
|
||||||
|
/// A live stream can't meaningfully pause; yielding the floor stops it.
|
||||||
|
/// The burst keeps assembling to file, so nothing is lost.
|
||||||
|
nonisolated func pauseForExclusivity() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
final class PTTCaptureEngine {
|
||||||
|
/// 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)
|
||||||
|
|
||||||
|
// 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 (main-actor
|
||||||
|
/// callers only; see `stopEngineIfStarted`).
|
||||||
|
private var engineStarted = false
|
||||||
|
|
||||||
|
/// Called on the capture queue with each batch of encoded AAC frames.
|
||||||
|
var onFrames: (([Data]) -> Void)?
|
||||||
|
|
||||||
|
enum CaptureError: Error {
|
||||||
|
case inputUnavailable
|
||||||
|
case audioSetupFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(outputURL: URL) throws {
|
||||||
|
#if os(iOS)
|
||||||
|
try Self.configureAudioSession()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 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) }
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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).
|
||||||
|
func stop() -> (url: URL?, encodedFrames: Int) {
|
||||||
|
stopEngineIfStarted()
|
||||||
|
let result: (URL?, Int) = queue.sync {
|
||||||
|
let url = fileURL
|
||||||
|
let frames = encodedFrameCount
|
||||||
|
teardown(deleteFile: false)
|
||||||
|
return (url, frames)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
Self.deactivateAudioSession()
|
||||||
|
#endif
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
stopEngineIfStarted()
|
||||||
|
queue.sync { teardown(deleteFile: true) }
|
||||||
|
#if os(iOS)
|
||||||
|
Self.deactivateAudioSession()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private func stopEngineIfStarted() {
|
||||||
|
guard engineStarted else { return }
|
||||||
|
engineStarted = false
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture queue
|
||||||
|
|
||||||
|
private func process(_ buffer: AVAudioPCMBuffer) {
|
||||||
|
guard 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio session (iOS)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private static func configureAudioSession() throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
#if targetEnvironment(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deactivateAudioSession() {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// PTTSettings.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// User preference for live push-to-talk voice. One switch controls both
|
||||||
|
/// directions: streaming your holds live, and auto-playing inbound bursts.
|
||||||
|
/// Off means voice messages behave exactly like classic voice notes.
|
||||||
|
enum PTTSettings {
|
||||||
|
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
|
||||||
|
|
||||||
|
static var liveVoiceEnabled: Bool {
|
||||||
|
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autoplay is foreground-only: audio must never start from the
|
||||||
|
/// background.
|
||||||
|
@MainActor
|
||||||
|
static var isAppActive: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return UIApplication.shared.applicationState == .active
|
||||||
|
#elseif os(macOS)
|
||||||
|
return NSApplication.shared.isActive
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// VoiceCaptureSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Capture backend behind the composer's hold-to-record gesture.
|
||||||
|
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
|
||||||
|
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
|
||||||
|
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
|
||||||
|
/// additionally streams frames live while the button is held.
|
||||||
|
@MainActor
|
||||||
|
protocol VoiceCaptureSession: AnyObject {
|
||||||
|
/// Whether audio is leaving the device in real time while recording —
|
||||||
|
/// drives the composer's LIVE treatment.
|
||||||
|
var isLive: Bool { get }
|
||||||
|
func requestPermission() async -> Bool
|
||||||
|
func start() async throws
|
||||||
|
/// Stops capture and returns the finalized voice-note file, or nil when
|
||||||
|
/// nothing valid was captured.
|
||||||
|
func finish() async -> URL?
|
||||||
|
func cancel() async
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
|
@MainActor
|
||||||
|
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||||
|
var isLive: Bool { false }
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
await VoiceRecorder.shared.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
try await VoiceRecorder.shared.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async -> URL? {
|
||||||
|
await VoiceRecorder.shared.stopRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() async {
|
||||||
|
await VoiceRecorder.shared.cancelRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = PTTCaptureEngine()
|
||||||
|
/// 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) {
|
||||||
|
self.burstID = VoiceBurstPacket.makeBurstID()
|
||||||
|
self.sendPacket = sendPacket
|
||||||
|
self.stream = StreamState(burstID: 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 capture.start(outputURL: outputURL)
|
||||||
|
} 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)
|
||||||
|
try capture.start(outputURL: outputURL)
|
||||||
|
}
|
||||||
|
startDate = Date()
|
||||||
|
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 { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
let (url, encodedFrames) = capture.stop()
|
||||||
|
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||||
|
|
||||||
|
guard elapsed >= 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((Double(encodedFrames) * PTTAudioFormat.frameDuration * 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 {
|
||||||
|
guard !completed else { return }
|
||||||
|
completed = true
|
||||||
|
capture.cancel()
|
||||||
|
sendControlPacket(.canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
|
sendPacket(packet.encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeOutputURL(burstID: Data) throws -> URL {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
@Published private(set) var duration: TimeInterval = 0
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
@Published private(set) var progress: Double = 0
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
|
/// rounded so 4.9s shows "00:05"
|
||||||
|
var roundedDuration: Int {
|
||||||
|
guard duration.isFinite else { return 0 }
|
||||||
|
return Int(duration.rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||||
|
var remainingSeconds: Int {
|
||||||
|
let remaining = max(0, duration - currentTime)
|
||||||
|
return min(roundedDuration, Int(ceil(remaining)))
|
||||||
|
}
|
||||||
|
|
||||||
private var player: AVAudioPlayer?
|
private var player: AVAudioPlayer?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var url: URL
|
private var url: URL
|
||||||
@@ -169,23 +181,35 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures only one voice note plays at a time.
|
/// Something that can hold the app's single audio-playback slot and yield it
|
||||||
|
/// when another playback starts (voice notes pause; live bursts stop).
|
||||||
|
protocol ExclusivePlayback: AnyObject {
|
||||||
|
func pauseForExclusivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||||
|
func pauseForExclusivity() {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures only one voice playback (note or live burst) runs at a time.
|
||||||
final class VoiceNotePlaybackCoordinator {
|
final class VoiceNotePlaybackCoordinator {
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
private weak var activeController: VoiceNotePlaybackController?
|
private weak var activeController: (any ExclusivePlayback)?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func activate(_ controller: VoiceNotePlaybackController) {
|
func activate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeController?.pause()
|
activeController?.pauseForExclusivity()
|
||||||
activeController = controller
|
activeController = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
func deactivate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
activeController = nil
|
activeController = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,24 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
actor VoiceRecorder {
|
||||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
|
||||||
case recordingInProgress
|
case recordingInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
static let shared = VoiceRecorder()
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
private let maxRecordingDuration: TimeInterval = 120
|
||||||
|
static let minRecordingDuration: TimeInterval = 1
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var stopWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
@discardableResult
|
nonisolated
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
@@ -47,106 +40,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
func startRecording() throws -> URL {
|
func startRecording() throws -> URL {
|
||||||
try queue.sync {
|
if recorder?.isRecording == true {
|
||||||
if recorder?.isRecording == true {
|
throw RecorderError.recordingInProgress
|
||||||
throw RecorderError.recordingInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
guard session.recordPermission == .granted else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
try session.setCategory(
|
|
||||||
.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
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: 16_000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVEncoderBitRateKey: 16_000
|
|
||||||
]
|
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
||||||
audioRecorder.delegate = self
|
|
||||||
audioRecorder.isMeteringEnabled = true
|
|
||||||
audioRecorder.prepareToRecord()
|
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
|
||||||
|
|
||||||
recorder = audioRecorder
|
|
||||||
currentURL = outputURL
|
|
||||||
stopWorkItem?.cancel()
|
|
||||||
stopWorkItem = nil
|
|
||||||
return outputURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
// allowBluetoothHFP is not available on iOS Simulator
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
try session.setCategory(
|
||||||
|
.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
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 16_000
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.isMeteringEnabled = true
|
||||||
|
audioRecorder.prepareToRecord()
|
||||||
|
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||||
|
|
||||||
|
recorder = audioRecorder
|
||||||
|
currentURL = outputURL
|
||||||
|
return outputURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
func stopRecording() async -> URL? {
|
||||||
queue.async { [weak self] in
|
guard let recorder, recorder.isRecording else {
|
||||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
return currentURL
|
||||||
completion(self?.currentURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = DispatchWorkItem { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
recorder.stop()
|
|
||||||
self.cleanupSession()
|
|
||||||
let url = self.currentURL
|
|
||||||
self.recorder = nil
|
|
||||||
self.currentURL = url
|
|
||||||
completion(url)
|
|
||||||
}
|
|
||||||
self.stopWorkItem = item
|
|
||||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sessionURL = currentURL
|
||||||
|
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||||
|
|
||||||
|
recorder.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
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRecording() {
|
func cancelRecording() {
|
||||||
queue.async { [weak self] in
|
if let recorder, recorder.isRecording {
|
||||||
guard let self = self else { return }
|
recorder.stop()
|
||||||
self.stopWorkItem?.cancel()
|
|
||||||
self.stopWorkItem = nil
|
|
||||||
if let recorder = self.recorder, recorder.isRecording {
|
|
||||||
recorder.stop()
|
|
||||||
}
|
|
||||||
self.cleanupSession()
|
|
||||||
if let url = self.currentURL {
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
self.recorder = nil
|
|
||||||
self.currentURL = nil
|
|
||||||
}
|
}
|
||||||
}
|
cleanupSession()
|
||||||
|
if let currentURL {
|
||||||
// MARK: - Metering
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
|
|
||||||
func currentAveragePower() -> Float {
|
|
||||||
queue.sync {
|
|
||||||
recorder?.updateMeters()
|
|
||||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
|
||||||
}
|
}
|
||||||
|
recorder = nil
|
||||||
|
currentURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ final class WaveformCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func purgeAll() {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
guard bins > 0 else { return nil }
|
guard bins > 0 else { return nil }
|
||||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
|||||||
@@ -81,14 +81,13 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - Three-Layer Identity Model
|
// MARK: - Three-Layer Identity Model
|
||||||
|
|
||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: PeerID // 8 random bytes
|
|
||||||
let sessionStart: Date
|
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +96,6 @@ enum HandshakeState {
|
|||||||
case initiated
|
case initiated
|
||||||
case inProgress
|
case inProgress
|
||||||
case completed(fingerprint: String)
|
case completed(fingerprint: String)
|
||||||
case failed(reason: String)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||||
@@ -109,7 +107,6 @@ struct CryptographicIdentity: Codable {
|
|||||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||||
var signingPublicKey: Data? = nil
|
var signingPublicKey: Data? = nil
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
let lastHandshake: Date?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||||
@@ -125,11 +122,35 @@ struct SocialIdentity: Codable {
|
|||||||
var notes: String?
|
var notes: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trust ladder: unknown → casual → vouched → trusted → verified.
|
||||||
|
///
|
||||||
|
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
|
||||||
|
/// value ("unknown", "casual", …), not by ordinal position, so inserting
|
||||||
|
/// `vouched` mid-ladder cannot corrupt previously persisted values — every
|
||||||
|
/// pre-existing case keeps the exact raw value it was written with. The
|
||||||
|
/// `vouched` tier is additionally never persisted into `SocialIdentity`
|
||||||
|
/// (it's recomputed on read from stored vouches), so downgraded builds never
|
||||||
|
/// encounter the unfamiliar raw value.
|
||||||
enum TrustLevel: String, Codable {
|
enum TrustLevel: String, Codable {
|
||||||
case unknown = "unknown"
|
case unknown
|
||||||
case casual = "casual"
|
case casual
|
||||||
case trusted = "trusted"
|
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
||||||
case verified = "verified"
|
/// Derived at read time — never written to persistent storage.
|
||||||
|
case vouched
|
||||||
|
case trusted
|
||||||
|
case verified
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Vouching (transitive verification)
|
||||||
|
|
||||||
|
/// One accepted vouch: a peer I verified (the voucher) attested that they
|
||||||
|
/// verified the vouchee. Validity is recomputed on read — a record only
|
||||||
|
/// counts while its voucher remains in `verifiedFingerprints` and its
|
||||||
|
/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher
|
||||||
|
/// silently invalidates the vouches they gave without a cascade delete.
|
||||||
|
struct VouchRecord: Codable, Equatable {
|
||||||
|
let voucherFingerprint: String
|
||||||
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Identity Cache
|
// MARK: - Identity Cache
|
||||||
@@ -153,9 +174,21 @@ struct IdentityCache: Codable {
|
|||||||
|
|
||||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
var blockedNostrPubkeys: Set<String> = []
|
var blockedNostrPubkeys: Set<String> = []
|
||||||
|
|
||||||
// Schema version for future migrations
|
// Vouching (transitive verification). All three fields are Optional so
|
||||||
var version: Int = 1
|
// 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
|
||||||
|
|
||||||
|
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
|
||||||
|
var vouchBatchSentAt: [String: Date]? = nil
|
||||||
|
|
||||||
|
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||||
|
// entries verified before this field exists sort as oldest)
|
||||||
|
var verifiedAt: [String: Date]? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -107,8 +108,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
func getFavorites() -> Set<String>
|
|
||||||
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
// MARK: Blocked Users Management
|
// MARK: Blocked Users Management
|
||||||
@@ -122,8 +121,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: PeerID)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
@@ -132,6 +130,16 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
func isVerified(fingerprint: String) -> Bool
|
func isVerified(fingerprint: String) -> Bool
|
||||||
func getVerifiedFingerprints() -> Set<String>
|
func getVerifiedFingerprints() -> Set<String>
|
||||||
|
|
||||||
|
// MARK: Vouching (transitive verification)
|
||||||
|
@discardableResult
|
||||||
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||||
|
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||||
|
func isVouched(fingerprint: String) -> Bool
|
||||||
|
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||||
|
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||||
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
|
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Singleton manager for secure identity state persistence and retrieval.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
@@ -150,38 +158,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
// Debouncing for keychain saves
|
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||||
private var saveTimer: Timer?
|
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
// 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
|
private var pendingSave = false
|
||||||
|
|
||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
private let encryptionKey: SymmetricKey
|
||||||
|
/// True when `encryptionKey` is a throwaway generated this session because the
|
||||||
|
/// persisted key could not be read (device locked / access denied). In that
|
||||||
|
/// state we must NOT persist (it would overwrite the real cache with data the
|
||||||
|
/// next launch can't decrypt) and must NOT delete the existing cache.
|
||||||
|
private let encryptionKeyIsEphemeral: Bool
|
||||||
|
|
||||||
init(_ keychain: KeychainManagerProtocol) {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// Generate or retrieve encryption key from keychain
|
// Retrieve (or, only on genuine first run, generate) the cache
|
||||||
|
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||||
|
// transient failure (device locked / access denied): the legacy
|
||||||
|
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||||
|
// new key deletes the existing one first — permanently orphaning the
|
||||||
|
// encrypted cache on a launch that merely couldn't read the key.
|
||||||
let loadedKey: SymmetricKey
|
let loadedKey: SymmetricKey
|
||||||
|
let keyIsEphemeral: Bool
|
||||||
// Try to load from keychain
|
|
||||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||||
|
case .success(let keyData):
|
||||||
loadedKey = SymmetricKey(data: keyData)
|
loadedKey = SymmetricKey(data: keyData)
|
||||||
|
keyIsEphemeral = false
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||||
}
|
|
||||||
// Generate new key if needed
|
case .itemNotFound:
|
||||||
else {
|
// Genuine first run: generate and persist a new key.
|
||||||
loadedKey = SymmetricKey(size: .bits256)
|
let newKey = SymmetricKey(size: .bits256)
|
||||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
let keyData = newKey.withUnsafeBytes { Data($0) }
|
||||||
// Save to keychain
|
|
||||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||||
|
loadedKey = newKey
|
||||||
|
// If even the save failed, treat the key as ephemeral so we don't
|
||||||
|
// later try to persist a cache the next launch can't read.
|
||||||
|
keyIsEphemeral = !saved
|
||||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||||
|
|
||||||
|
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
|
||||||
|
// Transient/critical read failure. Do NOT overwrite the persisted
|
||||||
|
// key. Use a session-only ephemeral key; the real key and cache are
|
||||||
|
// left intact for a healthy launch.
|
||||||
|
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
|
||||||
|
loadedKey = SymmetricKey(size: .bits256)
|
||||||
|
keyIsEphemeral = true
|
||||||
}
|
}
|
||||||
|
|
||||||
self.encryptionKey = loadedKey
|
self.encryptionKey = loadedKey
|
||||||
|
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||||
// Load identity cache on init
|
|
||||||
loadIdentityCache()
|
// Only read the persisted cache when we hold the real key; with an
|
||||||
|
// ephemeral key the decrypt would fail and discard the real cache.
|
||||||
|
if !keyIsEphemeral {
|
||||||
|
loadIdentityCache()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -201,28 +239,37 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||||
} catch {
|
} catch {
|
||||||
// Log error but continue with empty cache
|
cache = IdentityCache()
|
||||||
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||||
|
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||||
|
/// and persists it on the same serialized context — no timer, nothing left
|
||||||
|
/// scheduled to keep the process alive.
|
||||||
private func saveIdentityCache() {
|
private func saveIdentityCache() {
|
||||||
// Mark that we need to save
|
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
|
performSave()
|
||||||
// Cancel any existing timer
|
|
||||||
saveTimer?.invalidate()
|
|
||||||
|
|
||||||
// Schedule a new save after the debounce interval
|
|
||||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
|
||||||
self?.performSave()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||||
|
/// (barrier) access.
|
||||||
private func performSave() {
|
private func performSave() {
|
||||||
guard pendingSave else { return }
|
guard pendingSave else { return }
|
||||||
pendingSave = false
|
pendingSave = false
|
||||||
|
|
||||||
|
// Never persist under an ephemeral key — it would overwrite the real
|
||||||
|
// cache with data the next launch cannot decrypt.
|
||||||
|
guard !encryptionKeyIsEphemeral else {
|
||||||
|
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(cache)
|
let data = try JSONEncoder().encode(cache)
|
||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
@@ -234,10 +281,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate save (for app termination)
|
// 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() {
|
func forceSave() {
|
||||||
saveTimer?.invalidate()
|
|
||||||
performSave()
|
performSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,21 +318,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||||
firstSeen: existing.firstSeen,
|
firstSeen: existing.firstSeen
|
||||||
lastHandshake: now
|
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
} else {
|
} else {
|
||||||
// Update signing key and lastHandshake
|
// Update signing key
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
let updated = CryptographicIdentity(
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
fingerprint: existing.fingerprint,
|
|
||||||
publicKey: existing.publicKey,
|
|
||||||
signingPublicKey: existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = updated
|
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -290,8 +333,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
firstSeen: now,
|
firstSeen: now
|
||||||
lastHandshake: now
|
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
@@ -331,16 +373,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
|
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||||
|
|
||||||
// Update nickname index
|
// Update nickname index
|
||||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
if let previousClaimedNickname,
|
||||||
// Remove old nickname from index if changed
|
previousClaimedNickname != identity.claimedNickname {
|
||||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,11 +498,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||||
peerID: peerID,
|
|
||||||
sessionStart: Date(),
|
|
||||||
handshakeState: handshakeState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,16 +544,20 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
|
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||||
|
verifiedAt[fingerprint] = Date()
|
||||||
|
self.cache.verifiedAt = verifiedAt
|
||||||
} else {
|
} else {
|
||||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||||
|
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trust level if social identity exists
|
// Update trust level if social identity exists
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.trustLevel = verified ? .verified : .casual
|
identity.trustLevel = verified ? .verified : .casual
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
}
|
}
|
||||||
|
|
||||||
self.saveIdentityCache()
|
self.saveIdentityCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,4 +573,169 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
return cache.verifiedFingerprints
|
return cache.verifiedFingerprints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Vouching (transitive verification)
|
||||||
|
|
||||||
|
/// Maximum vouchers retained per vouchee (most recent kept).
|
||||||
|
static let maxVouchersPerVouchee = 8
|
||||||
|
|
||||||
|
/// Records an accepted vouch, enforcing every accept-policy gate that can
|
||||||
|
/// be evaluated against stored state (signature verification is the
|
||||||
|
/// caller's job — it needs the sender's announce-bound signing key):
|
||||||
|
/// - the voucher must be a fingerprint *I* verified
|
||||||
|
/// - self-vouches are ignored
|
||||||
|
/// - vouches for peers I already verified are ignored (nothing to add)
|
||||||
|
/// - attestations outside the validity window are ignored
|
||||||
|
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
|
||||||
|
///
|
||||||
|
/// Returns true when the vouch was stored (or refreshed).
|
||||||
|
@discardableResult
|
||||||
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||||
|
recordVouch(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucherFingerprint: voucherFingerprint,
|
||||||
|
timestamp: timestamp,
|
||||||
|
now: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
guard voucheeFingerprint != voucherFingerprint,
|
||||||
|
self.cache.verifiedFingerprints.contains(voucherFingerprint),
|
||||||
|
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let age = now.timeIntervalSince(timestamp)
|
||||||
|
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
|
||||||
|
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
|
||||||
|
let newest = max(records[index].timestamp, timestamp)
|
||||||
|
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
|
||||||
|
} else {
|
||||||
|
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
||||||
|
}
|
||||||
|
// Keep the most recent vouchers up to the cap.
|
||||||
|
records.sort { $0.timestamp > $1.timestamp }
|
||||||
|
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
|
||||||
|
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
|
||||||
|
return false // Full of fresher vouches; nothing changed.
|
||||||
|
}
|
||||||
|
|
||||||
|
var vouches = self.cache.vouchesByVouchee ?? [:]
|
||||||
|
vouches[voucheeFingerprint] = capped
|
||||||
|
self.cache.vouchesByVouchee = vouches
|
||||||
|
self.saveIdentityCache()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vouches that currently count for `fingerprint`. Validity is
|
||||||
|
/// recomputed here rather than maintained by cascade deletes: a record
|
||||||
|
/// only counts while its voucher is still verified-by-me and its
|
||||||
|
/// timestamp is within the expiry window.
|
||||||
|
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||||
|
validVouchers(for: fingerprint, now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||||
|
queue.sync {
|
||||||
|
self.validVouchersLocked(for: fingerprint, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requires `queue`.
|
||||||
|
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||||
|
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
|
||||||
|
return records.filter { record in
|
||||||
|
record.voucherFingerprint != fingerprint
|
||||||
|
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
|
||||||
|
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the peer has at least one valid vouch and no explicit
|
||||||
|
/// verification of ours.
|
||||||
|
func isVouched(fingerprint: String) -> Bool {
|
||||||
|
isVouched(fingerprint: fingerprint, now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVouched(fingerprint: String, now: Date) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
|
||||||
|
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trust level to display: explicit verification wins, then the
|
||||||
|
/// persisted level, with `vouched` layered in (derived, never persisted)
|
||||||
|
/// between `casual` and `trusted`.
|
||||||
|
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||||
|
effectiveTrustLevel(for: fingerprint, now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
|
||||||
|
queue.sync {
|
||||||
|
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
|
||||||
|
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
||||||
|
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||||
|
switch stored {
|
||||||
|
case .verified, .trusted:
|
||||||
|
return stored
|
||||||
|
case .vouched, .casual, .unknown:
|
||||||
|
if vouched { return .vouched }
|
||||||
|
// `.vouched` should never be persisted; degrade defensively.
|
||||||
|
return stored == .vouched ? .casual : stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||||
|
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
var sentAt = self.cache.vouchBatchSentAt ?? [:]
|
||||||
|
sentAt[fingerprint] = date
|
||||||
|
self.cache.vouchBatchSentAt = sentAt
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||||
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verified fingerprints ordered most recently verified first (entries
|
||||||
|
/// without a recorded verification time sort last), excluding the given
|
||||||
|
/// fingerprint. Feeds the outgoing vouch batch.
|
||||||
|
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||||
|
queue.sync {
|
||||||
|
let verifiedAt = cache.verifiedAt ?? [:]
|
||||||
|
let ordered = cache.verifiedFingerprints
|
||||||
|
.filter { $0 != fingerprint }
|
||||||
|
.sorted {
|
||||||
|
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
|
||||||
|
}
|
||||||
|
return Array(ordered.prefix(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugNicknameIndex: [String: Set<String>] {
|
||||||
|
queue.sync { cache.nicknameIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
||||||
|
queue.sync { ephemeralSessions[peerID] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugLastInteraction(for fingerprint: String) -> Date? {
|
||||||
|
queue.sync { cache.lastInteractions[fingerprint] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>AppGroupID</key>
|
||||||
|
<string>$(APP_GROUP_ID)</string>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -37,12 +39,12 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//
|
||||||
|
// BitchatMessage+Media.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
enum Media {
|
||||||
|
case voice(URL)
|
||||||
|
case image(URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
|
private struct Cache {
|
||||||
|
let filesDir: URL?
|
||||||
|
|
||||||
|
static let shared = Cache()
|
||||||
|
private init() {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
self.filesDir = filesDir
|
||||||
|
} catch {
|
||||||
|
filesDir = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaAttachment(for nickname: String) -> Media? {
|
||||||
|
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
||||||
|
|
||||||
|
func url(for category: MimeType.Category) -> URL? {
|
||||||
|
guard content.hasPrefix(category.messagePrefix),
|
||||||
|
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check outgoing first for sent messages, incoming for received
|
||||||
|
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
||||||
|
|
||||||
|
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||||
|
// Files are checked during playback/display, so missing files fail gracefully
|
||||||
|
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
||||||
|
return directory.appendingPathComponent(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = url(for: .audio) {
|
||||||
|
return .voice(url)
|
||||||
|
}
|
||||||
|
if let url = url(for: .image) {
|
||||||
|
return .image(url)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
let peerID: PeerID // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let isReachable: Bool
|
let isReachable: Bool
|
||||||
|
|
||||||
@@ -76,14 +76,13 @@ struct BitchatPeer: Equatable {
|
|||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen _: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.peerID = peerID
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isReachable = isReachable
|
self.isReachable = isReachable
|
||||||
|
|
||||||
|
|||||||
@@ -11,48 +11,78 @@ import Foundation
|
|||||||
// MARK: - CommandInfo Enum
|
// MARK: - CommandInfo Enum
|
||||||
|
|
||||||
enum CommandInfo: String, Identifiable {
|
enum CommandInfo: String, Identifiable {
|
||||||
|
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||||
|
// the suggestion panel is the app's only command-discovery surface, and
|
||||||
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
|
case group
|
||||||
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "dm"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
|
case pay
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite
|
case favorite = "fav"
|
||||||
case unfavorite
|
case unfavorite = "unfav"
|
||||||
|
case ping
|
||||||
|
case trace
|
||||||
|
case drop
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var alias: String { "/" + rawValue }
|
var alias: String { "/" + rawValue }
|
||||||
|
|
||||||
var placeholder: String? {
|
var placeholder: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
case .clear, .who:
|
case .group:
|
||||||
|
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
||||||
|
case .pay:
|
||||||
|
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||||
|
case .drop:
|
||||||
|
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
||||||
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .block: String(localized: "content.commands.block")
|
case .block: String(localized: "content.commands.block")
|
||||||
case .clear: String(localized: "content.commands.clear")
|
case .clear: String(localized: "content.commands.clear")
|
||||||
|
case .group: String(localized: "content.commands.group")
|
||||||
|
case .help: String(localized: "content.commands.help")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .hug: String(localized: "content.commands.hug")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .message: String(localized: "content.commands.message")
|
||||||
|
case .pay: String(localized: "content.commands.pay")
|
||||||
case .slap: String(localized: "content.commands.slap")
|
case .slap: String(localized: "content.commands.slap")
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
case .unblock: String(localized: "content.commands.unblock")
|
||||||
case .who: String(localized: "content.commands.who")
|
case .who: String(localized: "content.commands.who")
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
case .favorite: String(localized: "content.commands.favorite")
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
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] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||||
if isGeoPublic || isGeoDM {
|
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||||
|
// processor still allows it behind an explicit "public" confirm).
|
||||||
|
// Payments make sense in every DM and in mesh public.
|
||||||
|
if !isGeoPublic {
|
||||||
|
commands.append(.pay)
|
||||||
}
|
}
|
||||||
return baseCommands
|
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||||
|
// geohash contexts, so only suggest them where they work: mesh.
|
||||||
|
if isGeoPublic || isGeoDM {
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
|
|||||||
@@ -1,20 +1,58 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
|
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||||
|
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||||
|
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||||
|
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||||
|
// streams (targeted resync for stalled reassemblies)
|
||||||
struct RequestSyncPacket {
|
struct RequestSyncPacket {
|
||||||
|
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
||||||
|
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
||||||
|
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
||||||
|
static let maxFragmentIdFilterCount = 60
|
||||||
|
|
||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
let types: SyncTypeFlags?
|
let types: SyncTypeFlags?
|
||||||
|
let sinceTimestamp: UInt64?
|
||||||
|
let fragmentIdFilter: String?
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
||||||
|
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
||||||
|
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
||||||
|
let tokens = fragmentIDs
|
||||||
|
.filter { $0.count == 8 }
|
||||||
|
.prefix(maxFragmentIdFilterCount)
|
||||||
|
.map { $0.hexEncodedString() }
|
||||||
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
return tokens.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
||||||
|
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
||||||
|
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
||||||
|
guard let filter else { return nil }
|
||||||
|
var ids: Set<Data> = []
|
||||||
|
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
||||||
|
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
||||||
|
ids.insert(id)
|
||||||
|
}
|
||||||
|
return ids.isEmpty ? nil : ids
|
||||||
|
}
|
||||||
|
|
||||||
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||||
self.p = p
|
self.p = p
|
||||||
self.m = m
|
self.m = m
|
||||||
self.data = data
|
self.data = data
|
||||||
self.types = types
|
self.types = types
|
||||||
|
self.sinceTimestamp = sinceTimestamp
|
||||||
|
self.fragmentIdFilter = fragmentIdFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data {
|
func encode() -> Data {
|
||||||
@@ -36,15 +74,24 @@ struct RequestSyncPacket {
|
|||||||
if let typesData = types?.toData() {
|
if let typesData = types?.toData() {
|
||||||
putTLV(0x04, typesData)
|
putTLV(0x04, typesData)
|
||||||
}
|
}
|
||||||
|
if let ts = sinceTimestamp {
|
||||||
|
var tsBE = ts.bigEndian
|
||||||
|
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||||
|
}
|
||||||
|
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||||
|
putTLV(0x06, fidData)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||||
var off = 0
|
var off = 0
|
||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
var types: SyncTypeFlags? = nil
|
var types: SyncTypeFlags? = nil
|
||||||
|
var sinceTimestamp: UInt64? = nil
|
||||||
|
var fragmentIdFilter: String? = nil
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -68,12 +115,24 @@ struct RequestSyncPacket {
|
|||||||
if let decoded = SyncTypeFlags.decode(v) {
|
if let decoded = SyncTypeFlags.decode(v) {
|
||||||
types = decoded
|
types = decoded
|
||||||
}
|
}
|
||||||
|
case 0x05:
|
||||||
|
if v.count == 8 {
|
||||||
|
var ts: UInt64 = 0
|
||||||
|
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||||
|
sinceTimestamp = ts
|
||||||
|
}
|
||||||
|
case 0x06:
|
||||||
|
// Same acceptance cap as the GCS payload; an oversized filter
|
||||||
|
// is ignored rather than failing the whole request.
|
||||||
|
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
||||||
|
fragmentIdFilter = fid
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ enum NoisePattern {
|
|||||||
case XX // Most versatile, mutual authentication
|
case XX // Most versatile, mutual authentication
|
||||||
case IK // Initiator knows responder's static key
|
case IK // Initiator knows responder's static key
|
||||||
case NK // Anonymous initiator
|
case NK // Anonymous initiator
|
||||||
|
case X // One-way: single message to a known static key (no response)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NoiseRole {
|
enum NoiseRole {
|
||||||
@@ -165,19 +167,23 @@ final class NoiseCipherState {
|
|||||||
// MARK: - Sliding Window Replay Protection
|
// MARK: - Sliding Window Replay Protection
|
||||||
|
|
||||||
/// Check if nonce is valid for replay protection
|
/// Check if nonce is valid for replay protection
|
||||||
|
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
||||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||||
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
||||||
|
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
||||||
|
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
||||||
|
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
||||||
return false // Too old, outside window
|
return false // Too old, outside window
|
||||||
}
|
}
|
||||||
|
|
||||||
if receivedNonce > highestReceivedNonce {
|
if receivedNonce > highestReceivedNonce {
|
||||||
return true // Always accept newer nonces
|
return true // Always accept newer nonces
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||||
let byteIndex = offset / 8
|
let byteIndex = offset / 8
|
||||||
let bitIndex = offset % 8
|
let bitIndex = offset % 8
|
||||||
|
|
||||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +323,13 @@ final class NoiseCipherState {
|
|||||||
throw NoiseError.replayDetected
|
throw NoiseError.replayDetected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 4-byte nonce prefix has been stripped, so the remaining bytes
|
||||||
|
// must still hold at least the 16-byte Poly1305 tag. The up-front
|
||||||
|
// `ciphertext.count >= 16` guard is not sufficient here (it counts
|
||||||
|
// the nonce), and `prefix(count - 16)` would trap on a short payload.
|
||||||
|
guard actualCiphertext.count >= 16 else {
|
||||||
|
throw NoiseError.invalidCiphertext
|
||||||
|
}
|
||||||
// Split ciphertext and tag
|
// Split ciphertext and tag
|
||||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
tag = actualCiphertext.suffix(16)
|
tag = actualCiphertext.suffix(16)
|
||||||
@@ -347,16 +360,20 @@ final class NoiseCipherState {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||||
|
|
||||||
|
// BCH-01-010: Atomic nonce state update
|
||||||
|
// Both replay window marking and nonce increment must complete together
|
||||||
|
// to prevent state desynchronization. We perform both after successful
|
||||||
|
// decryption only, ensuring state consistency on any failure path.
|
||||||
if useExtractedNonce {
|
if useExtractedNonce {
|
||||||
// Mark nonce as seen after successful decryption
|
|
||||||
markNonceAsSeen(decryptionNonce)
|
markNonceAsSeen(decryptionNonce)
|
||||||
}
|
}
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
|
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
||||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
// Log authentication failures with nonce info
|
|
||||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -376,6 +393,16 @@ final class NoiseCipherState {
|
|||||||
replayWindow[i] = 0
|
replayWindow[i] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func setNonceForTesting(_ nonce: UInt64) {
|
||||||
|
self.nonce = nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
||||||
|
try extractNonceFromCiphertextPayload(combinedPayload)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Symmetric State
|
// MARK: - Symmetric State
|
||||||
@@ -455,13 +482,36 @@ final class NoiseSymmetricState {
|
|||||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||||
|
|
||||||
|
// BCH-01-010: Clear symmetric state after split per Noise spec
|
||||||
|
// The chaining key and hash should not be retained after handshake completes
|
||||||
|
clearSensitiveData()
|
||||||
|
|
||||||
return (c1, c2)
|
return (c1, c2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BCH-01-010: Securely clear sensitive cryptographic state
|
||||||
|
/// Called after split() to clear chaining key and hash per Noise spec
|
||||||
|
func clearSensitiveData() {
|
||||||
|
// Clear chaining key by overwriting with zeros
|
||||||
|
let chainingKeyCount = chainingKey.count
|
||||||
|
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
||||||
|
|
||||||
|
// Clear hash by overwriting with zeros
|
||||||
|
let hashCount = hash.count
|
||||||
|
hash = Data(repeating: 0, count: hashCount)
|
||||||
|
|
||||||
|
// Clear the internal cipher state
|
||||||
|
cipherState.clearSensitiveData()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
clearSensitiveData()
|
||||||
|
}
|
||||||
|
|
||||||
// HKDF implementation
|
// HKDF implementation
|
||||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||||
@@ -552,10 +602,11 @@ final class NoiseHandshakeState {
|
|||||||
switch pattern {
|
switch pattern {
|
||||||
case .XX:
|
case .XX:
|
||||||
break // No pre-message keys
|
break // No pre-message keys
|
||||||
case .IK, .NK:
|
case .IK, .NK, .X:
|
||||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||||
_ = symmetricState.getHandshakeHash()
|
|
||||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||||
|
} else if role == .responder, let localStatic = localStaticPublic {
|
||||||
|
symmetricState.mixHash(localStatic.rawRepresentation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,7 +723,7 @@ final class NoiseHandshakeState {
|
|||||||
return messageBuffer
|
return messageBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
|
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
|
||||||
|
|
||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
@@ -807,16 +858,20 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
return currentPattern >= messagePatterns.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
||||||
guard isHandshakeComplete() else {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
||||||
|
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
return role == .initiator ? (c1, c2) : (c2, c1)
|
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
||||||
|
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
@@ -826,6 +881,20 @@ final class NoiseHandshakeState {
|
|||||||
func getHandshakeHash() -> Data {
|
func getHandshakeHash() -> Data {
|
||||||
return symmetricState.getHandshakeHash()
|
return symmetricState.getHandshakeHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
||||||
|
try performDHOperation(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
||||||
|
self.currentPattern = currentPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
||||||
|
self.remoteEphemeralPublic = key
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pattern Extensions
|
// MARK: - Pattern Extensions
|
||||||
@@ -836,6 +905,7 @@ extension NoisePattern {
|
|||||||
case .XX: return "XX"
|
case .XX: return "XX"
|
||||||
case .IK: return "IK"
|
case .IK: return "IK"
|
||||||
case .NK: return "NK"
|
case .NK: return "NK"
|
||||||
|
case .X: return "X"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,6 +927,10 @@ extension NoisePattern {
|
|||||||
[.e, .es], // -> e, es
|
[.e, .es], // -> e, es
|
||||||
[.e, .ee] // <- e, ee
|
[.e, .ee] // <- e, ee
|
||||||
]
|
]
|
||||||
|
case .X:
|
||||||
|
return [
|
||||||
|
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,22 +951,47 @@ enum NoiseError: Error {
|
|||||||
case nonceExceeded
|
case nonceExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Constant-Time Operations
|
||||||
|
|
||||||
|
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
||||||
|
/// This function compares two Data objects in constant time, preventing
|
||||||
|
/// information leakage via timing analysis.
|
||||||
|
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
||||||
|
guard a.count == b.count else { return false }
|
||||||
|
|
||||||
|
var result: UInt8 = 0
|
||||||
|
for i in 0..<a.count {
|
||||||
|
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
||||||
|
}
|
||||||
|
return result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BCH-01-010: Constant-time check if all bytes are zero
|
||||||
|
private func constantTimeIsZero(_ data: Data) -> Bool {
|
||||||
|
var result: UInt8 = 0
|
||||||
|
for byte in data {
|
||||||
|
result |= byte
|
||||||
|
}
|
||||||
|
return result == 0
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Key Validation
|
// MARK: - Key Validation
|
||||||
|
|
||||||
extension NoiseHandshakeState {
|
extension NoiseHandshakeState {
|
||||||
/// Validate a Curve25519 public key
|
/// Validate a Curve25519 public key
|
||||||
/// Checks for weak/invalid keys that could compromise security
|
/// Checks for weak/invalid keys that could compromise security
|
||||||
|
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
||||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
// Check key length
|
// Check key length
|
||||||
guard keyData.count == 32 else {
|
guard keyData.count == 32 else {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for all-zero key (point at infinity)
|
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
||||||
if keyData.allSatisfy({ $0 == 0 }) {
|
if constantTimeIsZero(keyData) {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for low-order points that could enable small subgroup attacks
|
// Check for low-order points that could enable small subgroup attacks
|
||||||
// These are the known bad points for Curve25519
|
// These are the known bad points for Curve25519
|
||||||
let lowOrderPoints: [Data] = [
|
let lowOrderPoints: [Data] = [
|
||||||
@@ -913,13 +1012,21 @@ extension NoiseHandshakeState {
|
|||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||||
]
|
]
|
||||||
|
|
||||||
// Check against known bad points
|
// BCH-01-010: Constant-time check against known bad points
|
||||||
if lowOrderPoints.contains(keyData) {
|
// We check all points and accumulate matches to avoid early exit timing leaks
|
||||||
|
var foundBadPoint = false
|
||||||
|
for badPoint in lowOrderPoints {
|
||||||
|
if constantTimeCompare(keyData, badPoint) {
|
||||||
|
foundBadPoint = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundBadPoint {
|
||||||
SecureLogger.warning("Low-order point detected", category: .security)
|
SecureLogger.warning("Low-order point detected", category: .security)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to create the key - CryptoKit will validate curve points internally
|
// Try to create the key - CryptoKit will validate curve points internally
|
||||||
do {
|
do {
|
||||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ enum NoiseSecurityConstants {
|
|||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
static let maxHandshakesPerMinute = 10
|
static let maxHandshakesPerMinute = 10
|
||||||
static let maxMessagesPerSecond = 100
|
static let maxMessagesPerSecond = 100
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ enum NoiseSecurityError: Error {
|
|||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
case invalidPeerID
|
case invalidPeerID
|
||||||
case rateLimitExceeded
|
case rateLimitExceeded
|
||||||
case handshakeTimeout
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
@@ -102,23 +103,23 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers
|
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||||
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
// Store remote static key
|
// Store remote static key
|
||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = handshake.getHandshakeHash()
|
handshakeHash = hash
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
// Generate response
|
// Generate response
|
||||||
@@ -128,20 +129,20 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
// Get transport ciphers
|
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||||
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||||
sendCipher = send
|
sendCipher = send
|
||||||
receiveCipher = receive
|
receiveCipher = receive
|
||||||
|
|
||||||
// Store remote static key
|
// Store remote static key
|
||||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
// Store handshake hash for channel binding
|
// Store handshake hash for channel binding
|
||||||
handshakeHash = handshake.getHandshakeHash()
|
handshakeHash = hash
|
||||||
|
|
||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
@@ -21,9 +21,25 @@ final class NoiseSessionManager {
|
|||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
self.localStaticKey = localStaticKey
|
self.sessionFactory = { peerID, role in
|
||||||
self.keychain = keychain
|
SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
init(
|
||||||
|
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
||||||
|
keychain _: KeychainManagerProtocol,
|
||||||
|
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||||
|
) {
|
||||||
|
self.sessionFactory = sessionFactory
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
@@ -66,12 +82,7 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = SecureNoiseSession(
|
let session = sessionFactory(peerID, .initiator)
|
||||||
peerID: peerID,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -117,12 +128,7 @@ final class NoiseSessionManager {
|
|||||||
// Get or create session
|
// Get or create session
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
if shouldCreateNew {
|
if shouldCreateNew {
|
||||||
let newSession = SecureNoiseSession(
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
peerID: peerID,
|
|
||||||
role: .responder,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
final class SecureNoiseSession: NoiseSession {
|
||||||
private(set) var messageCount: UInt64 = 0
|
private(set) var messageCount: UInt64 = 0
|
||||||
private let sessionStartTime = Date()
|
private var sessionStartTime = Date()
|
||||||
private(set) var lastActivityTime = Date()
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
@@ -77,5 +77,9 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
func setMessageCountForTesting(_ count: UInt64) {
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
messageCount = count
|
messageCount = count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSessionStartTimeForTesting(_ date: Date) {
|
||||||
|
sessionStartTime = date
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Base64URLCoding {
|
||||||
|
static func encode(_ data: Data) -> String {
|
||||||
|
data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ string: String) -> Data? {
|
||||||
|
var base64 = string
|
||||||
|
let padding = (4 - (base64.count % 4)) % 4
|
||||||
|
if padding > 0 {
|
||||||
|
base64 += String(repeating: "=", count: padding)
|
||||||
|
}
|
||||||
|
base64 = base64
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
return Data(base64Encoded: base64)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,40 +7,160 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
/// Posted after the geo relay directory successfully refreshes its entries.
|
||||||
|
static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh")
|
||||||
|
}
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
|
struct GeoRelayDirectoryDependencies {
|
||||||
|
var userDefaults: UserDefaults
|
||||||
|
var notificationCenter: NotificationCenter
|
||||||
|
var now: () -> Date
|
||||||
|
var remoteURL: URL
|
||||||
|
var fetchInterval: TimeInterval
|
||||||
|
var refreshCheckInterval: TimeInterval
|
||||||
|
var retryInitialSeconds: TimeInterval
|
||||||
|
var retryMaxSeconds: TimeInterval
|
||||||
|
var awaitTorReady: @Sendable () async -> Bool
|
||||||
|
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
||||||
|
var readData: (URL) -> Data?
|
||||||
|
var writeData: (Data, URL) throws -> Void
|
||||||
|
var cacheURL: () -> URL?
|
||||||
|
var bundledCSVURLs: () -> [URL]
|
||||||
|
var currentDirectoryPath: () -> String?
|
||||||
|
var retrySleep: (TimeInterval) async -> Void
|
||||||
|
var activeNotificationName: Notification.Name?
|
||||||
|
var autoStart: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension GeoRelayDirectoryDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func live() -> Self {
|
||||||
|
#if os(iOS)
|
||||||
|
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||||
|
#elseif os(macOS)
|
||||||
|
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||||
|
#else
|
||||||
|
let activeNotificationName: Notification.Name? = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return Self(
|
||||||
|
userDefaults: .standard,
|
||||||
|
notificationCenter: .default,
|
||||||
|
now: Date.init,
|
||||||
|
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||||
|
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||||
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||||
|
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||||
|
makeFetchData: {
|
||||||
|
let session = TorURLSession.shared.session
|
||||||
|
return { request in
|
||||||
|
let (data, _) = try await session.data(for: request)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readData: { try? Data(contentsOf: $0) },
|
||||||
|
writeData: { data, url in
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
},
|
||||||
|
cacheURL: {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent("georelays_cache.csv")
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bundledCSVURLs: {
|
||||||
|
[
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
|
},
|
||||||
|
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||||
|
retrySleep: { delay in
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
},
|
||||||
|
activeNotificationName: activeNotificationName,
|
||||||
|
autoStart: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectory {
|
final class GeoRelayDirectory {
|
||||||
struct Entry: Hashable {
|
private final class CleanupState {
|
||||||
|
let notificationCenter: NotificationCenter
|
||||||
|
var observers: [NSObjectProtocol] = []
|
||||||
|
var refreshTimer: Timer?
|
||||||
|
var retryTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(notificationCenter: NotificationCenter) {
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { notificationCenter.removeObserver($0) }
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
retryTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entry: Hashable, Sendable {
|
||||||
let host: String
|
let host: String
|
||||||
let lat: Double
|
let lat: Double
|
||||||
let lon: Double
|
let lon: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum DetachedFetchOutcome: Sendable {
|
||||||
|
case success(entries: [Entry], csv: String)
|
||||||
|
case torNotReady
|
||||||
|
case invalidData
|
||||||
|
case network(String)
|
||||||
|
}
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let dependencies: GeoRelayDirectoryDependencies
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
private let cleanupState: CleanupState
|
||||||
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var retryTask: Task<Void, Never>?
|
|
||||||
private var retryAttempt: Int = 0
|
private var retryAttempt: Int = 0
|
||||||
private var isFetching: Bool = false
|
private var isFetching: Bool = false
|
||||||
private var observers: [NSObjectProtocol] = []
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
self.dependencies = .live()
|
||||||
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||||
entries = loadLocalEntries()
|
entries = loadLocalEntries()
|
||||||
registerObservers()
|
if dependencies.autoStart {
|
||||||
startRefreshTimer()
|
registerObservers()
|
||||||
prefetchIfNeeded()
|
startRefreshTimer()
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
self.dependencies = dependencies
|
||||||
refreshTimer?.invalidate()
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||||
retryTask?.cancel()
|
entries = loadLocalEntries()
|
||||||
|
if dependencies.autoStart {
|
||||||
|
registerObservers()
|
||||||
|
startRefreshTimer()
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
@@ -50,46 +170,29 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
|
/// Ties break by host so every device with the same directory picks the
|
||||||
|
/// same relay set — publishers and subscribers must agree on relays.
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty, count > 0 else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
|
|
||||||
if entries.count <= count {
|
return entries
|
||||||
return entries
|
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
|
||||||
.sorted { a, b in
|
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
.prefix(count)
|
||||||
}
|
.map { "wss://\($0.entry.host)" }
|
||||||
.map { "wss://\($0.host)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
var best: [(entry: Entry, distance: Double)] = []
|
|
||||||
best.reserveCapacity(count)
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
|
||||||
if best.count < count {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
best.removeLast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best.map { "wss://\($0.entry.host)" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
guard !isFetching else { return }
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = Date()
|
let now = dependencies.now()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
|
||||||
if !force {
|
if !force {
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||||
} else if last != .distantPast,
|
} else if last != .distantPast,
|
||||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,55 +206,79 @@ final class GeoRelayDirectory {
|
|||||||
isFetching = true
|
isFetching = true
|
||||||
|
|
||||||
let request = URLRequest(
|
let request = URLRequest(
|
||||||
url: remoteURL,
|
url: dependencies.remoteURL,
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
timeoutInterval: 15
|
timeoutInterval: 15
|
||||||
)
|
)
|
||||||
|
let awaitTorReady = dependencies.awaitTorReady
|
||||||
|
let fetchData = dependencies.makeFetchData()
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let outcome = await Self.fetchRemoteOutcome(
|
||||||
if !ready {
|
request: request,
|
||||||
await self.handleFetchFailure(.torNotReady)
|
awaitTorReady: awaitTorReady,
|
||||||
return
|
fetchData: fetchData
|
||||||
}
|
)
|
||||||
|
|
||||||
do {
|
switch outcome {
|
||||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
case .success(let parsed, let csv):
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||||
await self.handleFetchFailure(.invalidData)
|
case .torNotReady:
|
||||||
return
|
self.handleFetchFailure(.torNotReady)
|
||||||
}
|
case .invalidData:
|
||||||
|
self.handleFetchFailure(.invalidData)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
case .network(let description):
|
||||||
guard !parsed.isEmpty else {
|
self.handleFetchFailure(.network(description))
|
||||||
await self.handleFetchFailure(.invalidData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
await self.handleFetchFailure(.network(error))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func fetchRemoteOutcome(
|
||||||
|
request: URLRequest,
|
||||||
|
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||||
|
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||||
|
) async -> DetachedFetchOutcome {
|
||||||
|
await Task.detached(priority: .utility) {
|
||||||
|
let ready = await awaitTorReady()
|
||||||
|
guard ready else { return .torNotReady }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try await fetchData(request)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
return .invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = Self.parseCSV(text)
|
||||||
|
guard !parsed.isEmpty else {
|
||||||
|
return .invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(entries: parsed, csv: text)
|
||||||
|
} catch {
|
||||||
|
return .network(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
private enum FetchFailure {
|
private enum FetchFailure {
|
||||||
case torNotReady
|
case torNotReady
|
||||||
case invalidData
|
case invalidData
|
||||||
case network(Error)
|
case network(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
entries = parsed
|
entries = parsed
|
||||||
persistCache(csv)
|
persistCache(csv)
|
||||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
isFetching = false
|
isFetching = false
|
||||||
retryAttempt = 0
|
retryAttempt = 0
|
||||||
cancelRetry()
|
cancelRetry()
|
||||||
|
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
|
||||||
|
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -161,8 +288,8 @@ final class GeoRelayDirectory {
|
|||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||||
case .invalidData:
|
case .invalidData:
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||||
case .network(let error):
|
case .network(let errorDescription):
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||||
}
|
}
|
||||||
isFetching = false
|
isFetching = false
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
@@ -171,32 +298,34 @@ final class GeoRelayDirectory {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func scheduleRetry() {
|
private func scheduleRetry() {
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
retryAttempt = min(retryAttempt + 1, 10)
|
||||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
let base = dependencies.retryInitialSeconds
|
||||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
let maxDelay = dependencies.retryMaxSeconds
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||||
let calculated = base * multiplier
|
let calculated = base * multiplier
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
let delay = min(maxDelay, max(base, calculated))
|
||||||
|
|
||||||
cancelRetry()
|
cancelRetry()
|
||||||
retryTask = Task { [weak self] in
|
cleanupState.retryTask = Task { [weak self] in
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
guard let self else { return }
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
await self.dependencies.retrySleep(delay)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self?.prefetchIfNeeded(force: true)
|
self.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func cancelRetry() {
|
private func cancelRetry() {
|
||||||
retryTask?.cancel()
|
cleanupState.retryTask?.cancel()
|
||||||
retryTask = nil
|
cleanupState.retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = cacheURL() else { return }
|
guard let url = dependencies.cacheURL() else { return }
|
||||||
|
guard let data = text.data(using: .utf8) else { return }
|
||||||
do {
|
do {
|
||||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
try dependencies.writeData(data, url)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
@@ -205,22 +334,18 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = cacheURL(),
|
if let cache = dependencies.cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = dependencies.readData(cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
|
||||||
].compactMap { $0 }
|
|
||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = dependencies.readData(url),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
@@ -228,8 +353,8 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = dependencies.currentDirectoryPath(),
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
@@ -242,42 +367,20 @@ final class GeoRelayDirectory {
|
|||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||||
if line.isEmpty { continue }
|
|
||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
var host = parts[0]
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "http://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
|
||||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
return Array(result)
|
return Array(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cacheURL() -> URL? {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
// MARK: - Observers & Timers
|
||||||
private func registerObservers() {
|
private func registerObservers() {
|
||||||
let center = NotificationCenter.default
|
let center = dependencies.notificationCenter
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
let torReady = center.addObserver(
|
||||||
forName: .TorDidBecomeReady,
|
forName: .TorDidBecomeReady,
|
||||||
@@ -289,38 +392,26 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded(force: true)
|
self.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observers.append(torReady)
|
cleanupState.observers.append(torReady)
|
||||||
|
|
||||||
#if os(iOS)
|
if let activeNotificationName = dependencies.activeNotificationName {
|
||||||
let didBecomeActive = center.addObserver(
|
let didBecomeActive = center.addObserver(
|
||||||
forName: UIApplication.didBecomeActiveNotification,
|
forName: activeNotificationName,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
cleanupState.observers.append(didBecomeActive)
|
||||||
}
|
}
|
||||||
observers.append(didBecomeActive)
|
|
||||||
#elseif os(macOS)
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: NSApplication.didBecomeActiveNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(didBecomeActive)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
private func startRefreshTimer() {
|
||||||
refreshTimer?.invalidate()
|
cleanupState.refreshTimer?.invalidate()
|
||||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
let interval = dependencies.refreshCheckInterval
|
||||||
guard interval > 0 else { return }
|
guard interval > 0 else { return }
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
@@ -329,9 +420,13 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshTimer = timer
|
cleanupState.refreshTimer = timer
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugRetryAttempt: Int { retryAttempt }
|
||||||
|
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||||
|
var debugObserverCount: Int { cleanupState.observers.count }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Distance
|
// MARK: - Distance
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ struct NostrIdentity: Codable {
|
|||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
let npub: String // Bech32-encoded public key
|
let npub: String // Bech32-encoded public key
|
||||||
let createdAt: Date
|
|
||||||
|
|
||||||
/// Memberwise initializer
|
/// Memberwise initializer
|
||||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.npub = npub
|
self.npub = npub
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new Nostr identity
|
/// Generate a new Nostr identity
|
||||||
@@ -39,12 +37,6 @@ struct NostrIdentity: Codable {
|
|||||||
self.privateKey = privateKeyData
|
self.privateKey = privateKeyData
|
||||||
self.publicKey = xOnlyPubkey
|
self.publicKey = xOnlyPubkey
|
||||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||||
self.createdAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get signing key for event signatures
|
|
||||||
func signingKey() throws -> P256K.Signing.PrivateKey {
|
|
||||||
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Schnorr signing key for Nostr event signatures
|
/// Get Schnorr signing key for Nostr event signatures
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +37,6 @@ final class NostrIdentityBridge {
|
|||||||
return nostrIdentity
|
return nostrIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
/// Get Nostr public key associated with a Noise public key
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
@@ -56,31 +49,19 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
func clearAllAssociations() {
|
func clearAllAssociations() {
|
||||||
let query: [String: Any] = [
|
// Must go through the injected keychain, not raw SecItem calls:
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
// under test that keychain is in-memory, and a direct delete here
|
||||||
kSecAttrService as String: keychainService,
|
// would wipe the developer's real Nostr identity on every test run.
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
keychain.deleteAll(service: keychainService)
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache = nil
|
||||||
|
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||||
|
// actual secp256k1 private keys; if left cached, post-panic geohash
|
||||||
|
// messages would still be signed with pre-panic keys (linkable across the
|
||||||
|
// wipe) until the app is force-quit.
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache.removeAll()
|
||||||
|
cacheLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
@@ -105,6 +86,13 @@ final class NostrIdentityBridge {
|
|||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
|
||||||
|
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
|
||||||
|
/// geohash-chat identity for the same cell string.
|
||||||
|
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
|
||||||
|
try deriveIdentity(forGeohash: "bridge|" + cell)
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// NIP-13 proof-of-work for Nostr events.
|
||||||
|
///
|
||||||
|
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
||||||
|
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
||||||
|
/// events are scored (never hard-rejected — the network has clients that do
|
||||||
|
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
||||||
|
/// per-sender public rate limit, everything else keeps the strict limits.
|
||||||
|
enum NostrPoW {
|
||||||
|
|
||||||
|
// MARK: - Tuning
|
||||||
|
|
||||||
|
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
||||||
|
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
||||||
|
/// 100 ms on any supported device.
|
||||||
|
static let targetBits = 8
|
||||||
|
|
||||||
|
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
||||||
|
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
||||||
|
/// still applies). See `MessageRateLimiter.allow`.
|
||||||
|
static let rateLimitBypassBits = 8
|
||||||
|
|
||||||
|
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
||||||
|
/// steps down until a difficulty reachable in a small extra budget is
|
||||||
|
/// found and the message is sent anyway — mining never blocks sending.
|
||||||
|
static let miningTimeCap: TimeInterval = 2.0
|
||||||
|
|
||||||
|
/// Budget for each stepped-down attempt after the main cap (or a task
|
||||||
|
/// cancellation) hits.
|
||||||
|
private static let fallbackTimeCap: TimeInterval = 0.15
|
||||||
|
|
||||||
|
/// The hot loop checks the deadline and task cancellation every this many
|
||||||
|
/// hash attempts.
|
||||||
|
private static let checkInterval: UInt64 = 1024
|
||||||
|
|
||||||
|
/// The nonce value is a fixed-width hex counter so the serialized event
|
||||||
|
/// template can be mutated in place without reallocation.
|
||||||
|
private static let nonceLength = 16
|
||||||
|
|
||||||
|
// MARK: - Scoring
|
||||||
|
|
||||||
|
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
||||||
|
/// an event-ID hash).
|
||||||
|
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
||||||
|
var total = 0
|
||||||
|
for byte in bytes {
|
||||||
|
if byte == 0 {
|
||||||
|
total += 8
|
||||||
|
} else {
|
||||||
|
total += byte.leadingZeroBitCount
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated NIP-13 difficulty of an inbound event.
|
||||||
|
///
|
||||||
|
/// The committed target in the nonce tag is what counts: the actual
|
||||||
|
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
||||||
|
/// and the event scores 0), and work beyond the commitment earns no extra
|
||||||
|
/// credit — this stops spammers who mine a low target from getting lucky
|
||||||
|
/// high scores. Events without a well-formed commitment score 0.
|
||||||
|
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
||||||
|
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
||||||
|
nonceTag.count >= 3,
|
||||||
|
let committed = Int(nonceTag[2]),
|
||||||
|
committed > 0, committed <= 256,
|
||||||
|
let idData = Data(hexString: idHex)
|
||||||
|
else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return leadingZeroBits(idData) >= committed ? committed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mining
|
||||||
|
|
||||||
|
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
||||||
|
/// fields. Nonisolated async: runs off the calling actor.
|
||||||
|
///
|
||||||
|
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
||||||
|
/// task is cancelled — the committed target steps down (halving to 0,
|
||||||
|
/// which any hash satisfies) so the event still ships promptly with an
|
||||||
|
/// honest commitment at the difficulty actually reached. Returns nil only
|
||||||
|
/// if canonical serialization fails; the caller then sends unmined.
|
||||||
|
static func mineNonceTag(
|
||||||
|
pubkey: String,
|
||||||
|
createdAt: Int,
|
||||||
|
kind: Int,
|
||||||
|
tags: [[String]],
|
||||||
|
content: String,
|
||||||
|
targetBits: Int = NostrPoW.targetBits
|
||||||
|
) async -> [String]? {
|
||||||
|
var target = min(max(targetBits, 0), 256)
|
||||||
|
var budget = miningTimeCap
|
||||||
|
while true {
|
||||||
|
if let tag = mineAttempt(
|
||||||
|
pubkey: pubkey,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: kind,
|
||||||
|
baseTags: tags,
|
||||||
|
content: content,
|
||||||
|
targetBits: target,
|
||||||
|
budget: budget
|
||||||
|
) {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
// Target 0 succeeds on the first hash, so reaching it with nil
|
||||||
|
// means serialization itself failed — give up on mining.
|
||||||
|
if target == 0 { return nil }
|
||||||
|
target /= 2
|
||||||
|
budget = fallbackTimeCap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
||||||
|
/// the canonical serialization is built once and only the fixed-width
|
||||||
|
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
||||||
|
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
||||||
|
/// the template could not be built.
|
||||||
|
private static func mineAttempt(
|
||||||
|
pubkey: String,
|
||||||
|
createdAt: Int,
|
||||||
|
kind: Int,
|
||||||
|
baseTags: [[String]],
|
||||||
|
content: String,
|
||||||
|
targetBits: Int,
|
||||||
|
budget: TimeInterval
|
||||||
|
) -> [String]? {
|
||||||
|
let targetString = String(targetBits)
|
||||||
|
guard let template = serializedTemplate(
|
||||||
|
pubkey: pubkey,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: kind,
|
||||||
|
baseTags: baseTags,
|
||||||
|
content: content,
|
||||||
|
targetString: targetString
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var buffer = template.buffer
|
||||||
|
let nonceRange = template.nonceRange
|
||||||
|
|
||||||
|
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
||||||
|
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
||||||
|
var nonce = UInt64.random(in: .min ... .max)
|
||||||
|
var attempts: UInt64 = 0
|
||||||
|
|
||||||
|
while true {
|
||||||
|
// Write the nonce as 16 lowercase hex chars, in place.
|
||||||
|
var value = nonce
|
||||||
|
var index = nonceRange.upperBound
|
||||||
|
while index > nonceRange.lowerBound {
|
||||||
|
index -= 1
|
||||||
|
buffer[index] = hexDigits[Int(value & 0xF)]
|
||||||
|
value >>= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
||||||
|
// Identical to the bytes just written into the buffer.
|
||||||
|
return ["nonce", String(format: "%016llx", nonce), targetString]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce &+= 1
|
||||||
|
attempts &+= 1
|
||||||
|
if attempts % checkInterval == 0,
|
||||||
|
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
||||||
|
/// plus the byte range of the nonce value inside it.
|
||||||
|
///
|
||||||
|
/// The range is located by serializing twice with two same-length
|
||||||
|
/// placeholders and diffing the buffers — the only differing bytes are
|
||||||
|
/// the nonce value, so this stays correct however `JSONSerialization`
|
||||||
|
/// escapes the surrounding fields (and even if the content contains the
|
||||||
|
/// placeholder text itself).
|
||||||
|
private static func serializedTemplate(
|
||||||
|
pubkey: String,
|
||||||
|
createdAt: Int,
|
||||||
|
kind: Int,
|
||||||
|
baseTags: [[String]],
|
||||||
|
content: String,
|
||||||
|
targetString: String
|
||||||
|
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
||||||
|
func serialize(noncePlaceholder: String) -> Data? {
|
||||||
|
var tags = baseTags
|
||||||
|
tags.append(["nonce", noncePlaceholder, targetString])
|
||||||
|
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||||
|
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
||||||
|
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
||||||
|
zeros.count == effs.count
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstDiff = -1
|
||||||
|
var lastDiff = -1
|
||||||
|
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
||||||
|
if firstDiff < 0 { firstDiff = index }
|
||||||
|
lastDiff = index
|
||||||
|
}
|
||||||
|
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
||||||
|
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
|
case geohashPresence = 20001
|
||||||
|
case deletion = 5 // NIP-09 event deletion request
|
||||||
|
/// Sealed courier envelope parked on relays under its rotating
|
||||||
|
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
||||||
|
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||||
|
case courierDrop = 1401
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -38,22 +44,23 @@ struct NostrProtocol {
|
|||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Create ephemeral key for this message
|
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||||
// Created ephemeral key for seal
|
// so the recipient can authenticate who sent the message; signing with
|
||||||
|
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||||
// 3. Seal the rumor (encrypt to recipient)
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createSeal(
|
||||||
rumor: rumor,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: ephemeralKey
|
senderKey: senderKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||||
|
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||||
|
// its own ephemeral key internally).
|
||||||
let giftWrap = try createGiftWrap(
|
let giftWrap = try createGiftWrap(
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey
|
||||||
senderKey: ephemeralKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
// Created gift wrap
|
||||||
@@ -83,7 +90,15 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Open the seal
|
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||||
|
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||||
|
// who knows the recipient's npub. Verify the seal's own signature.
|
||||||
|
guard seal.isValidSignature() else {
|
||||||
|
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Open the seal
|
||||||
let rumor: NostrEvent
|
let rumor: NostrEvent
|
||||||
do {
|
do {
|
||||||
rumor = try openSeal(
|
rumor = try openSeal(
|
||||||
@@ -95,10 +110,63 @@ struct NostrProtocol {
|
|||||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
// 4. The sender claimed inside the rumor must match the key that actually
|
||||||
|
// signed the seal, otherwise the sender field is unauthenticated and
|
||||||
|
// spoofable.
|
||||||
|
guard seal.pubkey == rumor.pubkey else {
|
||||||
|
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the seal signer's pubkey as the authenticated sender.
|
||||||
|
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let rumor = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .dm,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
var seal = try createSeal(
|
||||||
|
rumor: rumor,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: senderIdentity.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
seal.sig = String(repeating: "0", count: 128)
|
||||||
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
rumorIdentity: NostrIdentity,
|
||||||
|
sealSignerIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let rumor = NostrEvent(
|
||||||
|
pubkey: rumorIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .dm,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let seal = try createSeal(
|
||||||
|
rumor: rumor,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
static func createEphemeralGeohashEvent(
|
static func createEphemeralGeohashEvent(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -107,13 +175,112 @@ struct NostrProtocol {
|
|||||||
nickname: String? = nil,
|
nickname: String? = nil,
|
||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
||||||
|
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
||||||
|
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
||||||
|
/// surrounding task is cancelled) the event ships at the highest
|
||||||
|
/// committed difficulty still met, and if mining is impossible it ships
|
||||||
|
/// unmined — sending is never blocked.
|
||||||
|
static func createMinedEphemeralGeohashEvent(
|
||||||
|
content: String,
|
||||||
|
geohash: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil,
|
||||||
|
teleported: Bool = false,
|
||||||
|
powTargetBits: Int = NostrPoW.targetBits
|
||||||
|
) async throws -> NostrEvent {
|
||||||
|
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
||||||
|
// Fix created_at up front: the mined nonce commits to the full
|
||||||
|
// serialized event, so the signed event must reuse the exact value.
|
||||||
|
let createdAt = Int(Date().timeIntervalSince1970)
|
||||||
|
if let nonceTag = await NostrPoW.mineNonceTag(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: createdAt,
|
||||||
|
kind: EventKind.ephemeralEvent.rawValue,
|
||||||
|
tags: tags,
|
||||||
|
content: content,
|
||||||
|
targetBits: powTargetBits
|
||||||
|
) {
|
||||||
|
tags.append(nonceTag)
|
||||||
|
}
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: tags,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
||||||
|
/// variants).
|
||||||
|
private static func ephemeralGeohashTags(
|
||||||
|
geohash: String,
|
||||||
|
nickname: String?,
|
||||||
|
teleported: Bool
|
||||||
|
) -> [[String]] {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if teleported {
|
if teleported {
|
||||||
tags.append(["t", "teleport"])
|
tags.append(["t", "teleport"])
|
||||||
}
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a geohash presence heartbeat (kind 20001)
|
||||||
|
/// Must contain empty content and NO nickname tag
|
||||||
|
static func createGeohashPresenceEvent(
|
||||||
|
geohash: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let tags = [["g", geohash]]
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .geohashPresence,
|
||||||
|
tags: tags,
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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` carries the original
|
||||||
|
/// mesh message ID so receivers dedup the bridged copy against the radio
|
||||||
|
/// copy by timeline ID.
|
||||||
|
static func createBridgeMeshEvent(
|
||||||
|
content: String,
|
||||||
|
cell: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil,
|
||||||
|
meshMessageID: String? = nil
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
var tags = [["r", cell]]
|
||||||
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
|
tags.append(["n", nickname])
|
||||||
|
}
|
||||||
|
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
|
||||||
|
tags.append(["m", meshMessageID])
|
||||||
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -125,17 +292,73 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
|
||||||
|
/// cell: empty content, `r` tag only — the bridge analogue of geohash
|
||||||
|
/// presence, counted into "people across the bridge".
|
||||||
|
static func createBridgePresenceEvent(
|
||||||
|
cell: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .geohashPresence,
|
||||||
|
tags: [["r", cell]],
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
|
||||||
|
/// parked on relays. `x` is the hex recipient tag the recipient (or a
|
||||||
|
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
|
||||||
|
/// the envelope expiry so honoring relays garbage-collect the drop. The
|
||||||
|
/// signing identity should be a throwaway — the envelope authenticates
|
||||||
|
/// its sender internally via Noise-X, and linking drops to a stable
|
||||||
|
/// publisher key would leak courier traffic patterns.
|
||||||
|
static func createCourierDropEvent(
|
||||||
|
envelope: Data,
|
||||||
|
recipientTagHex: String,
|
||||||
|
expiresAt: Date,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let tags = [
|
||||||
|
["x", recipientTagHex],
|
||||||
|
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
|
||||||
|
]
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .courierDrop,
|
||||||
|
tags: tags,
|
||||||
|
content: envelope.base64EncodedString()
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
|
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
||||||
|
/// drop the note in step with a bridged board post's expiry.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil
|
nickname: String? = nil,
|
||||||
|
expiresAt: Date? = nil,
|
||||||
|
urgent: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
|
if let expiresAt {
|
||||||
|
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
||||||
|
}
|
||||||
|
if urgent {
|
||||||
|
tags.append(["t", "urgent"])
|
||||||
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -146,7 +369,25 @@ struct NostrProtocol {
|
|||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
||||||
|
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
||||||
|
/// key that signed the original.
|
||||||
|
static func createDeleteEvent(
|
||||||
|
ofEventID eventID: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .deletion,
|
||||||
|
tags: [["e", eventID]],
|
||||||
|
content: ""
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
@@ -176,10 +417,9 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String
|
||||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let sealJSON = try seal.jsonString()
|
let sealJSON = try seal.jsonString()
|
||||||
|
|
||||||
// Create new ephemeral key for gift wrap
|
// Create new ephemeral key for gift wrap
|
||||||
@@ -284,7 +524,7 @@ struct NostrProtocol {
|
|||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + base64URLEncode(combined)
|
return "v2:" + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -295,7 +535,7 @@ struct NostrProtocol {
|
|||||||
// Expect NIP-44 v2 format
|
// Expect NIP-44 v2 format
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = base64URLDecode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
@@ -394,37 +634,6 @@ struct NostrProtocol {
|
|||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct version that doesn't try to add prefixes
|
|
||||||
private static func deriveSharedSecretDirect(
|
|
||||||
privateKey: P256K.Schnorr.PrivateKey,
|
|
||||||
publicKey: Data
|
|
||||||
) throws -> Data {
|
|
||||||
// Direct shared secret calculation
|
|
||||||
|
|
||||||
// Convert Schnorr private key to KeyAgreement private key
|
|
||||||
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
|
||||||
dataRepresentation: privateKey.dataRepresentation
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use the public key as-is (should already have prefix)
|
|
||||||
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
|
||||||
dataRepresentation: publicKey,
|
|
||||||
format: .compressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Perform ECDH
|
|
||||||
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
|
||||||
with: keyAgreementPublicKey,
|
|
||||||
format: .compressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert SharedSecret to Data
|
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
|
||||||
|
|
||||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
|
||||||
return sharedSecretData
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Add random offset to current time for privacy
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
// This prevents timing correlation attacks while the actual message timestamp
|
||||||
@@ -509,6 +718,26 @@ struct NostrEvent: Codable {
|
|||||||
signed.sig = signatureHex
|
signed.sig = signatureHex
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
||||||
|
/// Returns false when the signature is missing, malformed, or does not verify.
|
||||||
|
func isValidSignature() -> Bool {
|
||||||
|
guard let sig = sig,
|
||||||
|
let sigData = Data(hexString: sig),
|
||||||
|
let pubData = Data(hexString: pubkey),
|
||||||
|
sigData.count == 64,
|
||||||
|
pubData.count == 32,
|
||||||
|
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
||||||
|
let (expectedId, eventHash) = try? calculateEventId(),
|
||||||
|
expectedId == id
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageBytes = [UInt8](eventHash)
|
||||||
|
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||||
|
return xonly.isValid(signature, for: &messageBytes)
|
||||||
|
}
|
||||||
|
|
||||||
private func calculateEventId() throws -> (String, Data) {
|
private func calculateEventId() throws -> (String, Data) {
|
||||||
let serialized = [
|
let serialized = [
|
||||||
@@ -534,36 +763,18 @@ struct NostrEvent: Codable {
|
|||||||
|
|
||||||
enum NostrError: Error {
|
enum NostrError: Error {
|
||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidPrivateKey
|
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case signingFailed
|
|
||||||
case encryptionFailed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
static func base64URLEncode(_ data: Data) -> String {
|
|
||||||
return data.base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func base64URLDecode(_ s: String) -> Data? {
|
|
||||||
var str = s
|
|
||||||
let pad = (4 - (str.count % 4)) % 4
|
|
||||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
|
||||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
||||||
return Data(base64Encoded: str)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
salt: Data(),
|
salt: Data(),
|
||||||
info: "nip44-v2".data(using: .utf8)!,
|
info: Data("nip44-v2".utf8),
|
||||||
outputByteCount: 32
|
outputByteCount: 32
|
||||||
)
|
)
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NostrRelayURL {
|
||||||
|
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
||||||
|
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if !value.contains("://"), let defaultScheme {
|
||||||
|
value = "\(defaultScheme)://\(value)"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var components = URLComponents(string: value),
|
||||||
|
let rawScheme = components.scheme?.lowercased(),
|
||||||
|
let rawHost = components.host?.lowercased(),
|
||||||
|
!rawHost.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rawScheme {
|
||||||
|
case "wss", "https":
|
||||||
|
components.scheme = "wss"
|
||||||
|
if components.port == 443 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
case "ws", "http":
|
||||||
|
components.scheme = "ws"
|
||||||
|
if components.port == 80 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
components.host = rawHost
|
||||||
|
if components.path == "/" {
|
||||||
|
components.path = ""
|
||||||
|
}
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
return components.string
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directoryAddress(_ rawValue: String) -> String? {
|
||||||
|
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||||
|
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||||
|
normalized.removeFirst(prefix.count)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,4 +132,3 @@ private extension Data {
|
|||||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
/// - Efficient binary message encoding
|
/// - Efficient binary message encoding
|
||||||
/// - Message fragmentation for large payloads
|
/// - Message fragmentation for large payloads
|
||||||
/// - TTL-based routing for mesh networks
|
/// - TTL-based routing for mesh networks
|
||||||
/// - Privacy features like padding and timing obfuscation
|
/// - Privacy features: message padding and randomized relay jitter
|
||||||
/// - Integration points for end-to-end encryption
|
/// - Integration points for end-to-end encryption
|
||||||
///
|
///
|
||||||
/// ## Protocol Design
|
/// ## Protocol Design
|
||||||
@@ -38,18 +38,20 @@
|
|||||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||||
///
|
///
|
||||||
/// ## Security Considerations
|
/// ## Security Considerations
|
||||||
/// - Message padding obscures actual content length
|
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||||
/// - Timing obfuscation prevents traffic analysis
|
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
||||||
|
/// cover traffic or per-message timing obfuscation
|
||||||
/// - Integration with Noise Protocol for E2E encryption
|
/// - Integration with Noise Protocol for E2E encryption
|
||||||
/// - No persistent identifiers in protocol headers
|
/// - No persistent identifiers in protocol headers
|
||||||
///
|
///
|
||||||
/// ## Message Types
|
/// ## Message Types
|
||||||
/// - **Announce/Leave**: Peer presence notifications
|
/// - **Announce/Leave**: Peer presence notifications
|
||||||
/// - **Message**: User chat messages (broadcast or directed)
|
/// - **Message**: Public chat messages
|
||||||
/// - **Fragment**: Multi-part message handling
|
/// - **Fragment**: Multi-part message handling
|
||||||
/// - **Delivery/Read**: Message acknowledgments
|
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
||||||
/// - **Noise**: Encrypted channel establishment
|
/// all private payloads (messages, delivery acks, read receipts)
|
||||||
/// - **Version**: Protocol version negotiation
|
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
||||||
|
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
|
||||||
///
|
///
|
||||||
/// ## Future Extensions
|
/// ## Future Extensions
|
||||||
/// The protocol is designed to be extensible:
|
/// The protocol is designed to be extensible:
|
||||||
@@ -60,40 +62,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
// MARK: - Message Types
|
|
||||||
|
|
||||||
/// Simplified BitChat protocol message types.
|
|
||||||
/// Reduced from 24 types to just 6 essential ones.
|
|
||||||
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
|
||||||
enum MessageType: UInt8 {
|
|
||||||
// Public messages (unencrypted)
|
|
||||||
case announce = 0x01 // "I'm here" with nickname
|
|
||||||
case message = 0x02 // Public chat message
|
|
||||||
case leave = 0x03 // "I'm leaving"
|
|
||||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
|
||||||
|
|
||||||
// Noise encryption
|
|
||||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
|
||||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
|
||||||
|
|
||||||
// Fragmentation (simplified)
|
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .announce: return "announce"
|
|
||||||
case .message: return "message"
|
|
||||||
case .leave: return "leave"
|
|
||||||
case .requestSync: return "requestSync"
|
|
||||||
case .noiseHandshake: return "noiseHandshake"
|
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
|
||||||
case .fragment: return "fragment"
|
|
||||||
case .fileTransfer: return "fileTransfer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Noise Payload Types
|
// MARK: - Noise Payload Types
|
||||||
|
|
||||||
@@ -105,17 +74,28 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
|
// Private groups (0x04/0x05 reserved by other features)
|
||||||
|
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)
|
||||||
// Verification (QR-based OOB binding)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
|
// Transitive verification (web of trust)
|
||||||
|
case vouch = 0x12 // Batch of vouch attestations
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .groupInvite: return "groupInvite"
|
||||||
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
|
case .voiceFrame: return "voiceFrame"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
|
case .vouch: return "vouch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,35 +111,6 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delivery Status
|
|
||||||
|
|
||||||
// Delivery status for messages
|
|
||||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
|
||||||
case sending
|
|
||||||
case sent // Left our device
|
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
|
||||||
case read(by: String, at: Date) // Seen by recipient
|
|
||||||
case failed(reason: String)
|
|
||||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
|
||||||
|
|
||||||
var displayText: String {
|
|
||||||
switch self {
|
|
||||||
case .sending:
|
|
||||||
return "Sending..."
|
|
||||||
case .sent:
|
|
||||||
return "Sent"
|
|
||||||
case .delivered(let nickname, _):
|
|
||||||
return "Delivered to \(nickname)"
|
|
||||||
case .read(let nickname, _):
|
|
||||||
return "Read by \(nickname)"
|
|
||||||
case .failed(let reason):
|
|
||||||
return "Failed: \(reason)"
|
|
||||||
case .partiallyDelivered(let reached, let total):
|
|
||||||
return "Delivered to \(reached)/\(total)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
@@ -176,6 +127,12 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
|
|
||||||
|
// 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
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
@@ -195,6 +152,14 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
||||||
|
// Default empty implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
|
||||||
|
// Default empty implementation
|
||||||
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//
|
||||||
|
// BoardPackets.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Board wire format (MessageType.boardPost payloads)
|
||||||
|
//
|
||||||
|
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
||||||
|
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
||||||
|
// - 0x02: postID (16B random)
|
||||||
|
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
||||||
|
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
||||||
|
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
||||||
|
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
||||||
|
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
||||||
|
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
||||||
|
// - 0x09: flags (u8, bit0 = urgent) [post]
|
||||||
|
// - 0x0A: signature (64B Ed25519)
|
||||||
|
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
||||||
|
// Unknown TLVs are skipped for forward compatibility.
|
||||||
|
|
||||||
|
enum BoardWireConstants {
|
||||||
|
static let postIDLength = 16
|
||||||
|
static let signingKeyLength = 32
|
||||||
|
static let signatureLength = 64
|
||||||
|
static let contentMaxBytes = 512
|
||||||
|
static let nicknameMaxBytes = 64
|
||||||
|
static let geohashMaxLength = 12
|
||||||
|
/// Posts may live at most 7 days past their creation timestamp.
|
||||||
|
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
||||||
|
static let postSigningContext = "bitchat-board-v1"
|
||||||
|
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
||||||
|
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BoardTLVType: UInt8 {
|
||||||
|
case kind = 0x01
|
||||||
|
case postID = 0x02
|
||||||
|
case geohash = 0x03
|
||||||
|
case content = 0x04
|
||||||
|
case authorSigningKey = 0x05
|
||||||
|
case authorNickname = 0x06
|
||||||
|
case createdAt = 0x07
|
||||||
|
case expiresAt = 0x08
|
||||||
|
case flags = 0x09
|
||||||
|
case signature = 0x0A
|
||||||
|
case deletedAt = 0x0B
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BoardWireKind: UInt8 {
|
||||||
|
case post = 0x01
|
||||||
|
case tombstone = 0x02
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed, persistent bulletin-board notice.
|
||||||
|
struct BoardPostPacket: Equatable {
|
||||||
|
let postID: Data
|
||||||
|
/// Empty string scopes the post to the mesh-local board.
|
||||||
|
let geohash: String
|
||||||
|
let content: String
|
||||||
|
let authorSigningKey: Data
|
||||||
|
let authorNickname: String
|
||||||
|
let createdAt: UInt64
|
||||||
|
let expiresAt: UInt64
|
||||||
|
let flags: UInt8
|
||||||
|
let signature: Data
|
||||||
|
|
||||||
|
static let urgentFlag: UInt8 = 0x01
|
||||||
|
|
||||||
|
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
||||||
|
|
||||||
|
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
||||||
|
/// fields are length-prefixed so no two field combinations can collide.
|
||||||
|
static func signingBytes(
|
||||||
|
postID: Data,
|
||||||
|
geohash: String,
|
||||||
|
content: String,
|
||||||
|
authorSigningKey: Data,
|
||||||
|
authorNickname: String,
|
||||||
|
createdAt: UInt64,
|
||||||
|
expiresAt: UInt64,
|
||||||
|
flags: UInt8
|
||||||
|
) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
||||||
|
out.append(postID)
|
||||||
|
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
||||||
|
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
||||||
|
out.append(authorSigningKey)
|
||||||
|
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
||||||
|
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
||||||
|
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
||||||
|
out.append(flags)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingBytes: Data {
|
||||||
|
Self.signingBytes(
|
||||||
|
postID: postID,
|
||||||
|
geohash: geohash,
|
||||||
|
content: content,
|
||||||
|
authorSigningKey: authorSigningKey,
|
||||||
|
authorNickname: authorNickname,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
flags: flags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature() -> Bool {
|
||||||
|
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed deletion marker. Only the author's key can produce one; receivers
|
||||||
|
/// keep it until the post's original expiry so the delete outruns the post.
|
||||||
|
struct BoardTombstonePacket: Equatable {
|
||||||
|
let postID: Data
|
||||||
|
let authorSigningKey: Data
|
||||||
|
let deletedAt: UInt64
|
||||||
|
let signature: Data
|
||||||
|
|
||||||
|
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
||||||
|
out.append(postID)
|
||||||
|
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingBytes: Data {
|
||||||
|
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature() -> Bool {
|
||||||
|
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded board payload: either a live post or a tombstone.
|
||||||
|
enum BoardWire: Equatable {
|
||||||
|
case post(BoardPostPacket)
|
||||||
|
case tombstone(BoardTombstonePacket)
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
||||||
|
out.append(t.rawValue)
|
||||||
|
let len = UInt16(v.count)
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(v)
|
||||||
|
}
|
||||||
|
switch self {
|
||||||
|
case .post(let post):
|
||||||
|
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
||||||
|
putTLV(.postID, post.postID)
|
||||||
|
putTLV(.geohash, Data(post.geohash.utf8))
|
||||||
|
putTLV(.content, Data(post.content.utf8))
|
||||||
|
putTLV(.authorSigningKey, post.authorSigningKey)
|
||||||
|
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
||||||
|
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
||||||
|
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
||||||
|
putTLV(.flags, Data([post.flags]))
|
||||||
|
putTLV(.signature, post.signature)
|
||||||
|
case .tombstone(let tombstone):
|
||||||
|
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
||||||
|
putTLV(.postID, tombstone.postID)
|
||||||
|
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
||||||
|
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
||||||
|
putTLV(.signature, tombstone.signature)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structural decode; the caller must still verify the signature before
|
||||||
|
/// ingesting (`verifySignature()`).
|
||||||
|
static func decode(from data: Data) -> BoardWire? {
|
||||||
|
var off = data.startIndex
|
||||||
|
var kind: BoardWireKind?
|
||||||
|
var postID: Data?
|
||||||
|
var geohash: String?
|
||||||
|
var content: String?
|
||||||
|
var contentBytes = 0
|
||||||
|
var authorSigningKey: Data?
|
||||||
|
var authorNickname: String?
|
||||||
|
var nicknameBytes = 0
|
||||||
|
var createdAt: UInt64?
|
||||||
|
var expiresAt: UInt64?
|
||||||
|
var flags: UInt8?
|
||||||
|
var signature: Data?
|
||||||
|
var deletedAt: UInt64?
|
||||||
|
|
||||||
|
while off + 3 <= data.endIndex {
|
||||||
|
let t = data[off]; off += 1
|
||||||
|
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||||
|
guard off + len <= data.endIndex else { return nil }
|
||||||
|
let v = data.subdata(in: off..<(off + len)); off += len
|
||||||
|
switch BoardTLVType(rawValue: t) {
|
||||||
|
case .kind:
|
||||||
|
guard v.count == 1 else { return nil }
|
||||||
|
kind = BoardWireKind(rawValue: v[v.startIndex])
|
||||||
|
case .postID:
|
||||||
|
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
||||||
|
postID = v
|
||||||
|
case .geohash:
|
||||||
|
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
||||||
|
geohash = String(data: v, encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
||||||
|
contentBytes = v.count
|
||||||
|
content = String(data: v, encoding: .utf8)
|
||||||
|
case .authorSigningKey:
|
||||||
|
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
||||||
|
authorSigningKey = v
|
||||||
|
case .authorNickname:
|
||||||
|
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
||||||
|
nicknameBytes = v.count
|
||||||
|
authorNickname = String(data: v, encoding: .utf8)
|
||||||
|
case .createdAt:
|
||||||
|
createdAt = BoardWireEncoding.uint64(from: v)
|
||||||
|
case .expiresAt:
|
||||||
|
expiresAt = BoardWireEncoding.uint64(from: v)
|
||||||
|
case .flags:
|
||||||
|
guard v.count == 1 else { return nil }
|
||||||
|
flags = v[v.startIndex]
|
||||||
|
case .signature:
|
||||||
|
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
||||||
|
signature = v
|
||||||
|
case .deletedAt:
|
||||||
|
deletedAt = BoardWireEncoding.uint64(from: v)
|
||||||
|
case nil:
|
||||||
|
continue // forward compatible; ignore unknown TLVs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let postID, let authorSigningKey, let signature else { return nil }
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case .post:
|
||||||
|
guard let geohash, let content, let authorNickname,
|
||||||
|
let createdAt, let expiresAt, let flags,
|
||||||
|
contentBytes >= 1,
|
||||||
|
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
||||||
|
isValidGeohashField(geohash),
|
||||||
|
expiresAt > createdAt,
|
||||||
|
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .post(BoardPostPacket(
|
||||||
|
postID: postID,
|
||||||
|
geohash: geohash,
|
||||||
|
content: content,
|
||||||
|
authorSigningKey: authorSigningKey,
|
||||||
|
authorNickname: authorNickname,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
flags: flags,
|
||||||
|
signature: signature
|
||||||
|
))
|
||||||
|
case .tombstone:
|
||||||
|
guard let deletedAt else { return nil }
|
||||||
|
return .tombstone(BoardTombstonePacket(
|
||||||
|
postID: postID,
|
||||||
|
authorSigningKey: authorSigningKey,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
signature: signature
|
||||||
|
))
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySignature() -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .post(let post): return post.verifySignature()
|
||||||
|
case .tombstone(let tombstone): return tombstone.verifySignature()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
||||||
|
/// Avoids a full decode on the hot relay path.
|
||||||
|
static func urgentFlag(in data: Data) -> Bool {
|
||||||
|
var off = data.startIndex
|
||||||
|
while off + 3 <= data.endIndex {
|
||||||
|
let t = data[off]; off += 1
|
||||||
|
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||||
|
guard off + len <= data.endIndex else { return false }
|
||||||
|
if t == BoardTLVType.flags.rawValue, len == 1 {
|
||||||
|
return data[off] & BoardPostPacket.urgentFlag != 0
|
||||||
|
}
|
||||||
|
off += len
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
||||||
|
/// base32 alphabet.
|
||||||
|
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
||||||
|
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BoardWireEncoding {
|
||||||
|
static func appendContext(_ context: String, to out: inout Data) {
|
||||||
|
let bytes = Data(context.utf8)
|
||||||
|
out.append(UInt8(min(bytes.count, 255)))
|
||||||
|
out.append(bytes.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
||||||
|
let len = UInt16(min(value.count, Int(UInt16.max)))
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(value.prefix(Int(UInt16.max)))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
||||||
|
var be = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func uint64Data(_ value: UInt64) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
appendUInt64(value, to: &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func uint64(from data: Data) -> UInt64? {
|
||||||
|
guard data.count == 8 else { return nil }
|
||||||
|
var value: UInt64 = 0
|
||||||
|
for byte in data { value = (value << 8) | UInt64(byte) }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
||||||
|
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return key.isValidSignature(signature, for: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,11 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Validates a geohash string for building-level precision (8 characters).
|
/// Validates a geohash string at any channel precision (1-12 characters).
|
||||||
/// - Parameter geohash: The geohash string to validate
|
/// - Parameter geohash: The geohash string to validate
|
||||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||||
guard geohash.count == 8 else { return false }
|
guard (1...12).contains(geohash.count) else { return false }
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
case .city: return 5
|
case .city: return 5
|
||||||
case .province: return 4
|
case .province: return 4
|
||||||
case .region: return 2
|
case .region: return 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// NostrCarrierPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
|
||||||
|
/// Nostr event ferried over the mesh between a mesh-only peer and an
|
||||||
|
/// internet gateway peer.
|
||||||
|
///
|
||||||
|
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
|
||||||
|
/// a mesh-only sender asks the gateway to publish its locally signed
|
||||||
|
/// geohash event to Nostr relays.
|
||||||
|
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
|
||||||
|
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
|
||||||
|
///
|
||||||
|
/// The carried event is public geohash chat — already plaintext on Nostr —
|
||||||
|
/// so the carrier adds no encryption. It IS signed by the originator's
|
||||||
|
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
|
||||||
|
/// or alter it undetected: gateways and receivers verify the Schnorr
|
||||||
|
/// signature before acting on it.
|
||||||
|
///
|
||||||
|
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
|
||||||
|
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
|
||||||
|
/// for forward compatibility.
|
||||||
|
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
|
||||||
|
let geohash: String
|
||||||
|
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
|
||||||
|
/// content, sig).
|
||||||
|
let eventJSON: Data
|
||||||
|
|
||||||
|
/// BLE airtime cap for a carried event.
|
||||||
|
static let maxEventJSONBytes = 16 * 1024
|
||||||
|
static let maxGeohashLength = 12
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case direction = 0x01
|
||||||
|
case geohash = 0x02
|
||||||
|
case eventJSON = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(direction: Direction, geohash: String, eventJSON: Data) {
|
||||||
|
let geohashBytes = Data(geohash.utf8)
|
||||||
|
guard !geohashBytes.isEmpty,
|
||||||
|
geohashBytes.count <= Self.maxGeohashLength,
|
||||||
|
!eventJSON.isEmpty,
|
||||||
|
eventJSON.count <= Self.maxEventJSONBytes else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.direction = direction
|
||||||
|
self.geohash = geohash
|
||||||
|
self.eventJSON = eventJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(direction: Direction, geohash: String, event: NostrEvent) {
|
||||||
|
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
|
||||||
|
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the carried event. Callers MUST still verify
|
||||||
|
/// `event.isValidSignature()` before publishing or displaying it.
|
||||||
|
func event() -> NostrEvent? {
|
||||||
|
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try? NostrEvent(from: dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
|
||||||
|
|
||||||
|
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||||
|
data.append(type.rawValue)
|
||||||
|
data.append(UInt8((value.count >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value.count & 0xFF))
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTLV(.direction, Data([direction.rawValue]))
|
||||||
|
appendTLV(.geohash, Data(geohash.utf8))
|
||||||
|
appendTLV(.eventJSON, eventJSON)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> NostrCarrierPacket? {
|
||||||
|
// Defensive slice re-base (Data slices keep parent indices).
|
||||||
|
let data = Data(data)
|
||||||
|
var offset = 0
|
||||||
|
var direction: Direction?
|
||||||
|
var geohash: String?
|
||||||
|
var eventJSON: Data?
|
||||||
|
|
||||||
|
while offset + 3 <= data.count {
|
||||||
|
let typeRaw = data[offset]
|
||||||
|
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
|
||||||
|
offset += 3
|
||||||
|
guard offset + length <= data.count else { return nil }
|
||||||
|
let value = data.subdata(in: offset..<offset + length)
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
switch TLVType(rawValue: typeRaw) {
|
||||||
|
case .direction:
|
||||||
|
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
|
||||||
|
direction = parsed
|
||||||
|
case .geohash:
|
||||||
|
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
|
||||||
|
geohash = parsed
|
||||||
|
case .eventJSON:
|
||||||
|
eventJSON = value
|
||||||
|
case nil:
|
||||||
|
// Unknown TLV; skip (tolerant decoder for forward compatibility).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard offset == data.count,
|
||||||
|
let direction,
|
||||||
|
let geohash,
|
||||||
|
let eventJSON else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Protocol TLV Packets
|
// MARK: - Protocol TLV Packets
|
||||||
@@ -7,12 +8,35 @@ struct AnnouncementPacket {
|
|||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
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
|
||||||
|
) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.noisePublicKey = noisePublicKey
|
||||||
|
self.signingPublicKey = signingPublicKey
|
||||||
|
self.directNeighbors = directNeighbors
|
||||||
|
self.capabilities = capabilities
|
||||||
|
self.bridgeGeohash = bridgeGeohash
|
||||||
|
}
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
case directNeighbors = 0x04
|
||||||
|
case capabilities = 0x05
|
||||||
|
case bridgeGeohash = 0x06
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -48,6 +72,24 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLV for capabilities (optional)
|
||||||
|
if let capabilities = capabilities {
|
||||||
|
let capabilityBytes = capabilities.encoded()
|
||||||
|
guard capabilityBytes.count <= 255 else { return nil }
|
||||||
|
data.append(TLVType.capabilities.rawValue)
|
||||||
|
data.append(UInt8(capabilityBytes.count))
|
||||||
|
data.append(capabilityBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLV for bridge rendezvous cell (optional; old clients skip it)
|
||||||
|
if let bridgeGeohash = bridgeGeohash,
|
||||||
|
let cellData = bridgeGeohash.data(using: .utf8),
|
||||||
|
!cellData.isEmpty, cellData.count <= 12 {
|
||||||
|
data.append(TLVType.bridgeGeohash.rawValue)
|
||||||
|
data.append(UInt8(cellData.count))
|
||||||
|
data.append(cellData)
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +99,8 @@ struct AnnouncementPacket {
|
|||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
|
var capabilities: PeerCapabilities?
|
||||||
|
var bridgeGeohash: String?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -87,6 +131,12 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
directNeighbors = neighbors
|
directNeighbors = neighbors
|
||||||
}
|
}
|
||||||
|
case .capabilities:
|
||||||
|
capabilities = PeerCapabilities(encoded: Data(value))
|
||||||
|
case .bridgeGeohash:
|
||||||
|
if length <= 12 {
|
||||||
|
bridgeGeohash = String(data: value, encoding: .utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -99,7 +149,9 @@ struct AnnouncementPacket {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors
|
directNeighbors: directNeighbors,
|
||||||
|
capabilities: capabilities,
|
||||||
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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]
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// VoiceBurstPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Audio codec of a live voice burst. START packets carry it so receivers can
|
||||||
|
/// reject bursts they can't decode instead of feeding garbage to the decoder.
|
||||||
|
enum VoiceBurstCodec: UInt8 {
|
||||||
|
/// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so
|
||||||
|
/// the finalized `.m4a` and the live frames come from the same encoder
|
||||||
|
/// settings.
|
||||||
|
case aacLC16kMono = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One packet of a live push-to-talk voice burst (the inner payload of
|
||||||
|
/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload
|
||||||
|
/// of `MessageType.voiceFrame`).
|
||||||
|
///
|
||||||
|
/// Wire format:
|
||||||
|
/// ```
|
||||||
|
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…]
|
||||||
|
/// ```
|
||||||
|
/// - flags 0x01 (START): payload = [codec: UInt8]
|
||||||
|
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
|
||||||
|
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
|
||||||
|
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
|
||||||
|
struct VoiceBurstPacket: Equatable {
|
||||||
|
enum Kind: Equatable {
|
||||||
|
case start(codec: VoiceBurstCodec)
|
||||||
|
case frames([Data])
|
||||||
|
case end(totalDataPackets: UInt16, durationMs: UInt32)
|
||||||
|
case canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
static let burstIDSize = 8
|
||||||
|
private static let headerSize = burstIDSize + 2 + 1
|
||||||
|
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
|
||||||
|
static let maxFramesPerPacket = 8
|
||||||
|
|
||||||
|
private enum Flags {
|
||||||
|
static let start: UInt8 = 0x01
|
||||||
|
static let end: UInt8 = 0x02
|
||||||
|
static let canceled: UInt8 = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
let burstID: Data
|
||||||
|
let seq: UInt16
|
||||||
|
let kind: Kind
|
||||||
|
|
||||||
|
init?(burstID: Data, seq: UInt16, kind: Kind) {
|
||||||
|
guard burstID.count == Self.burstIDSize else { return nil }
|
||||||
|
if case .frames(let frames) = kind {
|
||||||
|
guard !frames.isEmpty,
|
||||||
|
frames.count <= Self.maxFramesPerPacket,
|
||||||
|
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
|
||||||
|
else { return nil }
|
||||||
|
}
|
||||||
|
self.burstID = burstID
|
||||||
|
self.seq = seq
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var data = Data(capacity: Self.headerSize + payloadSize)
|
||||||
|
data.append(burstID)
|
||||||
|
data.append(UInt8((seq >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(seq & 0xFF))
|
||||||
|
switch kind {
|
||||||
|
case .start(let codec):
|
||||||
|
data.append(Flags.start)
|
||||||
|
data.append(codec.rawValue)
|
||||||
|
case .frames(let frames):
|
||||||
|
data.append(0)
|
||||||
|
for frame in frames {
|
||||||
|
let length = UInt16(frame.count)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
data.append(frame)
|
||||||
|
}
|
||||||
|
case .end(let totalDataPackets, let durationMs):
|
||||||
|
data.append(Flags.end)
|
||||||
|
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(totalDataPackets & 0xFF))
|
||||||
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
case .canceled:
|
||||||
|
data.append(Flags.canceled)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> VoiceBurstPacket? {
|
||||||
|
// Work on a re-based copy so subscripting is offset-safe.
|
||||||
|
let data = Data(data)
|
||||||
|
guard data.count >= headerSize else { return nil }
|
||||||
|
|
||||||
|
let burstID = data.prefix(burstIDSize)
|
||||||
|
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
|
||||||
|
let flags = data[burstIDSize + 2]
|
||||||
|
let payload = data.dropFirst(headerSize)
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
switch flags {
|
||||||
|
case Flags.start:
|
||||||
|
guard let codecByte = payload.first,
|
||||||
|
let codec = VoiceBurstCodec(rawValue: codecByte)
|
||||||
|
else { return nil }
|
||||||
|
kind = .start(codec: codec)
|
||||||
|
case Flags.end:
|
||||||
|
guard payload.count >= 6 else { return nil }
|
||||||
|
let bytes = Array(payload)
|
||||||
|
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
|
||||||
|
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||||
|
kind = .end(totalDataPackets: total, durationMs: duration)
|
||||||
|
case Flags.canceled:
|
||||||
|
kind = .canceled
|
||||||
|
case 0:
|
||||||
|
var frames: [Data] = []
|
||||||
|
var offset = payload.startIndex
|
||||||
|
while offset < payload.endIndex {
|
||||||
|
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
|
||||||
|
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
|
||||||
|
offset = payload.index(offset, offsetBy: 2)
|
||||||
|
guard length > 0,
|
||||||
|
payload.distance(from: offset, to: payload.endIndex) >= length,
|
||||||
|
frames.count < maxFramesPerPacket
|
||||||
|
else { return nil }
|
||||||
|
let end = payload.index(offset, offsetBy: length)
|
||||||
|
frames.append(Data(payload[offset..<end]))
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
guard !frames.isEmpty else { return nil }
|
||||||
|
kind = .frames(frames)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeBurstID() -> Data {
|
||||||
|
var bytes = Data(count: burstIDSize)
|
||||||
|
let result = bytes.withUnsafeMutableBytes {
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
|
||||||
|
}
|
||||||
|
guard result == errSecSuccess else {
|
||||||
|
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private var payloadSize: Int {
|
||||||
|
switch kind {
|
||||||
|
case .start: return 1
|
||||||
|
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
|
||||||
|
case .end: return 6
|
||||||
|
case .canceled: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy packetizer for outgoing bursts: batches encoded frames into
|
||||||
|
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
|
||||||
|
/// packet in a single BLE frame after Noise encryption and padding.
|
||||||
|
/// Not thread-safe — confine to one queue.
|
||||||
|
struct VoiceBurstPacketizer {
|
||||||
|
let burstID: Data
|
||||||
|
private let budget: Int
|
||||||
|
private var pendingFrames: [Data] = []
|
||||||
|
private var pendingSize = 0
|
||||||
|
/// seq 0 is reserved for START; data packets start at 1.
|
||||||
|
private(set) var nextSeq: UInt16 = 1
|
||||||
|
private(set) var dataPacketCount: UInt16 = 0
|
||||||
|
|
||||||
|
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
|
||||||
|
self.burstID = burstID
|
||||||
|
self.budget = budget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one encoded frame, returning any packets that became full.
|
||||||
|
/// Frames larger than the budget are dropped (the encoder's ~130-byte
|
||||||
|
/// frames never hit this; it guards against misconfiguration looping).
|
||||||
|
mutating func add(_ frame: Data) -> [Data] {
|
||||||
|
let frameCost = 2 + frame.count
|
||||||
|
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
|
||||||
|
|
||||||
|
var packets: [Data] = []
|
||||||
|
if !pendingFrames.isEmpty,
|
||||||
|
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|
||||||
|
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
|
||||||
|
packets.append(contentsOf: flush())
|
||||||
|
}
|
||||||
|
pendingFrames.append(frame)
|
||||||
|
pendingSize += frameCost
|
||||||
|
return packets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits any buffered frames as a final data packet.
|
||||||
|
mutating func flush() -> [Data] {
|
||||||
|
guard !pendingFrames.isEmpty,
|
||||||
|
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
|
||||||
|
else {
|
||||||
|
pendingFrames = []
|
||||||
|
pendingSize = 0
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
pendingFrames = []
|
||||||
|
pendingSize = 0
|
||||||
|
nextSeq &+= 1
|
||||||
|
dataPacketCount &+= 1
|
||||||
|
return [packet.encode()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//
|
||||||
|
// VouchAttestation.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A signed statement that the *sender of the enclosing Noise payload* has
|
||||||
|
/// verified the identity described here ("transitive verification").
|
||||||
|
///
|
||||||
|
/// The voucher's identity is deliberately implicit: attestations only travel
|
||||||
|
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
|
||||||
|
/// receiver verifies the Ed25519 signature against the session peer's
|
||||||
|
/// announce-bound signing key and stores the vouch keyed by that peer's
|
||||||
|
/// fingerprint. Nothing in the attestation names the voucher, so a captured
|
||||||
|
/// attestation cannot be replayed by a third party whose signing key doesn't
|
||||||
|
/// match.
|
||||||
|
///
|
||||||
|
/// Wire format — single attestation (TLV, 1-byte type + 1-byte length):
|
||||||
|
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
|
||||||
|
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
|
||||||
|
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
|
||||||
|
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
|
||||||
|
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
|
||||||
|
///
|
||||||
|
/// Unknown TLV types are skipped for forward compatibility.
|
||||||
|
///
|
||||||
|
/// Batch format (the `vouch` Noise payload body):
|
||||||
|
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
|
||||||
|
struct VouchAttestation: Equatable {
|
||||||
|
static let signingContext = "bitchat-vouch-v1"
|
||||||
|
/// Receiver-side expiry for attestations.
|
||||||
|
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
||||||
|
/// Tolerated clock skew for attestations timestamped in the future.
|
||||||
|
static let maxClockSkew: TimeInterval = 60 * 60
|
||||||
|
/// Upper bound of attestations carried/accepted in one batch payload.
|
||||||
|
static let maxBatchCount = 16
|
||||||
|
|
||||||
|
static let fingerprintSize = 32
|
||||||
|
static let signingKeySize = 32
|
||||||
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
let voucheeFingerprint: Data // 32 bytes
|
||||||
|
let voucheeSigningKey: Data // 32 bytes
|
||||||
|
let timestampMs: UInt64
|
||||||
|
let signature: Data // 64 bytes
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case voucheeFingerprint = 0x01
|
||||||
|
case voucheeSigningKey = 0x02
|
||||||
|
case timestamp = 0x03
|
||||||
|
case signature = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
|
||||||
|
|
||||||
|
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
|
||||||
|
|
||||||
|
/// The exact bytes the voucher signs.
|
||||||
|
static func signableBytes(
|
||||||
|
voucheeFingerprint: Data,
|
||||||
|
voucheeSigningKey: Data,
|
||||||
|
timestampMs: UInt64
|
||||||
|
) -> Data {
|
||||||
|
var message = Data(signingContext.utf8)
|
||||||
|
message.append(voucheeFingerprint)
|
||||||
|
message.append(voucheeSigningKey)
|
||||||
|
var timestampBE = timestampMs.bigEndian
|
||||||
|
withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) }
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
var signableBytes: Data {
|
||||||
|
Self.signableBytes(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucheeSigningKey: voucheeSigningKey,
|
||||||
|
timestampMs: timestampMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
|
||||||
|
/// signing primitive (e.g. `Transport.noiseSignData`).
|
||||||
|
static func build(
|
||||||
|
voucheeFingerprint: Data,
|
||||||
|
voucheeSigningKey: Data,
|
||||||
|
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
sign: (Data) -> Data?
|
||||||
|
) -> VouchAttestation? {
|
||||||
|
guard voucheeFingerprint.count == fingerprintSize,
|
||||||
|
voucheeSigningKey.count == signingKeySize else { return nil }
|
||||||
|
let message = signableBytes(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucheeSigningKey: voucheeSigningKey,
|
||||||
|
timestampMs: timestampMs
|
||||||
|
)
|
||||||
|
guard let signature = sign(message), signature.count == signatureSize else { return nil }
|
||||||
|
return VouchAttestation(
|
||||||
|
voucheeFingerprint: voucheeFingerprint,
|
||||||
|
voucheeSigningKey: voucheeSigningKey,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the Ed25519 signature against the voucher's announce-bound
|
||||||
|
/// signing key.
|
||||||
|
func verifySignature(voucherSigningKey: Data) -> Bool {
|
||||||
|
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return publicKey.isValidSignature(signature, for: signableBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the attestation is outside its validity window (older than
|
||||||
|
/// `maxAge`, or timestamped implausibly far in the future).
|
||||||
|
func isExpired(now: Date = Date()) -> Bool {
|
||||||
|
let age = now.timeIntervalSince(timestamp)
|
||||||
|
return age > Self.maxAge || age < -Self.maxClockSkew
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encoding
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
guard voucheeFingerprint.count == Self.fingerprintSize,
|
||||||
|
voucheeSigningKey.count == Self.signingKeySize,
|
||||||
|
signature.count == Self.signatureSize else { return nil }
|
||||||
|
var data = Data()
|
||||||
|
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||||
|
data.append(type.rawValue)
|
||||||
|
data.append(UInt8(value.count))
|
||||||
|
data.append(value)
|
||||||
|
}
|
||||||
|
appendTLV(.voucheeFingerprint, voucheeFingerprint)
|
||||||
|
appendTLV(.voucheeSigningKey, voucheeSigningKey)
|
||||||
|
var timestampBE = timestampMs.bigEndian
|
||||||
|
appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) })
|
||||||
|
appendTLV(.signature, signature)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> VouchAttestation? {
|
||||||
|
var fingerprint: Data?
|
||||||
|
var signingKey: Data?
|
||||||
|
var timestampMs: UInt64?
|
||||||
|
var signature: Data?
|
||||||
|
|
||||||
|
var offset = data.startIndex
|
||||||
|
while offset < data.endIndex {
|
||||||
|
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
|
||||||
|
offset + 1 < data.endIndex else { return nil }
|
||||||
|
let type = data[offset]
|
||||||
|
let length = Int(data[offset + 1])
|
||||||
|
let valueStart = offset + 2
|
||||||
|
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let value = Data(data[valueStart..<valueEnd])
|
||||||
|
switch TLVType(rawValue: type) {
|
||||||
|
case .voucheeFingerprint:
|
||||||
|
guard value.count == fingerprintSize else { return nil }
|
||||||
|
fingerprint = value
|
||||||
|
case .voucheeSigningKey:
|
||||||
|
guard value.count == signingKeySize else { return nil }
|
||||||
|
signingKey = value
|
||||||
|
case .timestamp:
|
||||||
|
guard value.count == 8 else { return nil }
|
||||||
|
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .signature:
|
||||||
|
guard value.count == signatureSize else { return nil }
|
||||||
|
signature = value
|
||||||
|
case nil:
|
||||||
|
break // Unknown TLV: skip for forward compatibility.
|
||||||
|
}
|
||||||
|
offset = valueEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
|
||||||
|
return VouchAttestation(
|
||||||
|
voucheeFingerprint: fingerprint,
|
||||||
|
voucheeSigningKey: signingKey,
|
||||||
|
timestampMs: timestampMs,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch encoding
|
||||||
|
|
||||||
|
/// Encodes up to `maxBatchCount` attestations into one payload body.
|
||||||
|
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
|
||||||
|
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
|
||||||
|
var data = Data()
|
||||||
|
data.append(UInt8(attestations.count))
|
||||||
|
for attestation in attestations {
|
||||||
|
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
|
||||||
|
var lengthBE = UInt16(encoded.count).bigEndian
|
||||||
|
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
|
||||||
|
data.append(encoded)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a batch payload, dropping malformed entries and ignoring
|
||||||
|
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
|
||||||
|
static func decodeList(from data: Data) -> [VouchAttestation] {
|
||||||
|
guard data.count > 1 else { return [] }
|
||||||
|
let declaredCount = Int(data[data.startIndex])
|
||||||
|
let limit = min(declaredCount, maxBatchCount)
|
||||||
|
var attestations: [VouchAttestation] = []
|
||||||
|
var offset = data.startIndex + 1
|
||||||
|
while attestations.count < limit, offset < data.endIndex {
|
||||||
|
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
|
||||||
|
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
|
||||||
|
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
|
||||||
|
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
|
||||||
|
attestations.append(attestation)
|
||||||
|
}
|
||||||
|
offset = entryEnd
|
||||||
|
}
|
||||||
|
return attestations
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,7 @@ import Foundation
|
|||||||
/// Manages autocomplete functionality for chat
|
/// Manages autocomplete functionality for chat
|
||||||
final class AutocompleteService {
|
final class AutocompleteService {
|
||||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
|
||||||
|
|
||||||
private let commands = [
|
|
||||||
"/msg", "/who", "/clear",
|
|
||||||
"/hug", "/slap", "/fav", "/unfav",
|
|
||||||
"/block", "/unblock"
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Get autocomplete suggestions for current text
|
/// Get autocomplete suggestions for current text
|
||||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||||
let textToPosition = String(text.prefix(cursorPosition))
|
let textToPosition = String(text.prefix(cursorPosition))
|
||||||
@@ -73,26 +66,6 @@ final class AutocompleteService {
|
|||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
|
||||||
guard let regex = commandRegex else { return nil }
|
|
||||||
|
|
||||||
let nsText = text as NSString
|
|
||||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
|
||||||
|
|
||||||
guard let match = matches.last else { return nil }
|
|
||||||
|
|
||||||
let fullRange = match.range(at: 0)
|
|
||||||
let captureRange = match.range(at: 1)
|
|
||||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
|
||||||
|
|
||||||
let suggestions = commands
|
|
||||||
.filter { $0.hasPrefix("/\(prefix)") }
|
|
||||||
.sorted()
|
|
||||||
.prefix(5)
|
|
||||||
|
|
||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func needsArgument(command: String) -> Bool {
|
private func needsArgument(command: String) -> Bool {
|
||||||
switch command {
|
switch command {
|
||||||
case "/who", "/clear":
|
case "/who", "/clear":
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Narrow environment for `BLEAnnounceHandler`.
|
||||||
|
///
|
||||||
|
/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor
|
||||||
|
/// UI notification, delayed re-announce) live inside the closures supplied by
|
||||||
|
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
|
||||||
|
struct BLEAnnounceHandlerEnvironment {
|
||||||
|
/// Local peer identity at the time the announce is handled.
|
||||||
|
let localPeerID: () -> PeerID
|
||||||
|
/// TTL value used for direct (non-relayed) packets.
|
||||||
|
let messageTTL: UInt8
|
||||||
|
/// Current time source.
|
||||||
|
let now: () -> Date
|
||||||
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
|
let existingNoisePublicKey: (PeerID) -> Data?
|
||||||
|
/// Verifies the packet signature against the announced signing key.
|
||||||
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
|
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
||||||
|
/// Runs the registry mutation phase under the collections barrier.
|
||||||
|
let withRegistryBarrier: (() -> Void) -> Void
|
||||||
|
/// Upserts the verified announce into the peer registry.
|
||||||
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
|
let upsertVerifiedAnnounce: (
|
||||||
|
_ peerID: PeerID,
|
||||||
|
_ announcement: AnnouncementPacket,
|
||||||
|
_ isConnected: Bool,
|
||||||
|
_ now: Date
|
||||||
|
) -> BLEPeerAnnounceUpdate
|
||||||
|
/// Debounced reconnect-log decision.
|
||||||
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
|
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||||
|
/// Records verified direct-neighbor claims in the mesh topology.
|
||||||
|
let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void
|
||||||
|
/// Persists the announced cryptographic identity for offline verification.
|
||||||
|
let persistIdentity: (AnnouncementPacket) -> Void
|
||||||
|
/// Announce-back dedup check.
|
||||||
|
let dedupContains: (String) -> Bool
|
||||||
|
/// Announce-back dedup marking.
|
||||||
|
let dedupMarkProcessed: (String) -> Void
|
||||||
|
/// Delivers the announce UI events as one ordered main-actor hop:
|
||||||
|
/// `.peerConnected` (if flagged) → initial gossip sync scheduling (if
|
||||||
|
/// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`.
|
||||||
|
/// A single closure keeps the original in-order delivery guarantee that
|
||||||
|
/// separate unstructured tasks would not provide.
|
||||||
|
let deliverAnnounceUIEvents: (
|
||||||
|
_ peerID: PeerID,
|
||||||
|
_ notifyPeerConnected: Bool,
|
||||||
|
_ scheduleInitialSync: Bool
|
||||||
|
) -> Void
|
||||||
|
/// Tracks the announce packet for gossip sync.
|
||||||
|
let trackPacketSeen: (BitchatPacket) -> Void
|
||||||
|
/// Reciprocates the announce for bidirectional discovery.
|
||||||
|
let sendAnnounceBack: () -> Void
|
||||||
|
/// Schedules a delayed re-announce (afterglow) after the given delay.
|
||||||
|
let scheduleAfterglow: (TimeInterval) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of an accepted announce, surfaced so the service can run
|
||||||
|
/// follow-up work (e.g. courier handover) that keys off the announce.
|
||||||
|
struct BLEAnnounceHandlingResult {
|
||||||
|
let peerID: PeerID
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
let isDirectAnnounce: Bool
|
||||||
|
let isVerified: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||||
|
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||||
|
/// gossip tracking, and the reciprocal announce response.
|
||||||
|
final class BLEAnnounceHandler {
|
||||||
|
private let environment: BLEAnnounceHandlerEnvironment
|
||||||
|
|
||||||
|
init(environment: BLEAnnounceHandlerEnvironment) {
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
|
||||||
|
let env = environment
|
||||||
|
let now = env.now()
|
||||||
|
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||||
|
packet: packet,
|
||||||
|
from: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
switch preflight {
|
||||||
|
case .accept(let acceptance):
|
||||||
|
announcement = acceptance.announcement
|
||||||
|
case .reject(.malformed):
|
||||||
|
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
return nil
|
||||||
|
case .reject(.senderMismatch(let derivedFromKey)):
|
||||||
|
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return nil
|
||||||
|
case .reject(.selfAnnounce):
|
||||||
|
return nil
|
||||||
|
case .reject(.stale(let ageSeconds)):
|
||||||
|
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
|
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||||
|
let hasSignature = packet.signature != nil
|
||||||
|
let signatureValid: Bool
|
||||||
|
if hasSignature {
|
||||||
|
signatureValid = env.verifySignature(packet, announcement.signingPublicKey)
|
||||||
|
if !signatureValid {
|
||||||
|
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signatureValid = false
|
||||||
|
}
|
||||||
|
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: hasSignature,
|
||||||
|
signatureValid: signatureValid,
|
||||||
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
|
announcedNoisePublicKey: announcement.noisePublicKey
|
||||||
|
)
|
||||||
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
|
}
|
||||||
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
|
var isNewPeer = false
|
||||||
|
var isReconnectedPeer = false
|
||||||
|
let directLinkState = env.linkState(peerID)
|
||||||
|
let isDirectAnnounce = packet.ttl == env.messageTTL
|
||||||
|
|
||||||
|
env.withRegistryBarrier {
|
||||||
|
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||||
|
let hasCentralSubscription = directLinkState.hasCentral
|
||||||
|
|
||||||
|
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||||
|
if !verifiedAnnounce {
|
||||||
|
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
// Reset flags to prevent post-barrier code from acting on unverified announces
|
||||||
|
isNewPeer = false
|
||||||
|
isReconnectedPeer = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let update = env.upsertVerifiedAnnounce(
|
||||||
|
peerID,
|
||||||
|
announcement,
|
||||||
|
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
isNewPeer = update.isNewPeer
|
||||||
|
isReconnectedPeer = update.wasDisconnected
|
||||||
|
|
||||||
|
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
||||||
|
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
||||||
|
let now = env.now()
|
||||||
|
if update.isNewPeer {
|
||||||
|
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
||||||
|
} else if update.wasDisconnected {
|
||||||
|
if env.shouldEmitReconnectLog(peerID, now) {
|
||||||
|
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
||||||
|
}
|
||||||
|
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
|
||||||
|
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update topology with verified neighbor claims (only for authenticated announces)
|
||||||
|
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
|
||||||
|
env.updateTopology(peerID, neighbors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist cryptographic identity and signing key for robust offline
|
||||||
|
// verification — only for verified announces. Persisting unverified
|
||||||
|
// announces would let an attacker who replays a victim's noisePublicKey
|
||||||
|
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||||
|
if verifiedAnnounce {
|
||||||
|
env.persistIdentity(announcement)
|
||||||
|
}
|
||||||
|
|
||||||
|
let announceBackID = "announce-back-\(peerID)"
|
||||||
|
let shouldSendBack = !env.dedupContains(announceBackID)
|
||||||
|
if shouldSendBack {
|
||||||
|
env.dedupMarkProcessed(announceBackID)
|
||||||
|
}
|
||||||
|
let responsePlan = BLEAnnounceResponsePolicy.plan(
|
||||||
|
isDirectAnnounce: isDirectAnnounce,
|
||||||
|
isNewPeer: isNewPeer,
|
||||||
|
isReconnectedPeer: isReconnectedPeer,
|
||||||
|
shouldSendAnnounceBack: shouldSendBack
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only notify of connection for new or reconnected peers when it is a
|
||||||
|
// direct announce; the list update always follows in the same hop.
|
||||||
|
env.deliverAnnounceUIEvents(
|
||||||
|
peerID,
|
||||||
|
responsePlan.shouldNotifyPeerConnected,
|
||||||
|
responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track for sync (include our own and others' announces)
|
||||||
|
env.trackPacketSeen(packet)
|
||||||
|
|
||||||
|
if responsePlan.shouldSendAnnounceBack {
|
||||||
|
// Reciprocate announce for bidirectional discovery
|
||||||
|
// Force send to ensure the peer receives our announce
|
||||||
|
env.sendAnnounceBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
||||||
|
if responsePlan.shouldScheduleAfterglow {
|
||||||
|
let delay = Double.random(in: 0.3...0.6)
|
||||||
|
env.scheduleAfterglow(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEAnnounceHandlingResult(
|
||||||
|
peerID: peerID,
|
||||||
|
announcement: announcement,
|
||||||
|
isDirectAnnounce: isDirectAnnounce,
|
||||||
|
isVerified: verifiedAnnounce
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEAnnouncePreflightAcceptance {
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
let derivedPeerID: PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightRejection: Equatable {
|
||||||
|
case malformed
|
||||||
|
case senderMismatch(derivedPeerID: PeerID)
|
||||||
|
case selfAnnounce
|
||||||
|
case stale(ageSeconds: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightDecision {
|
||||||
|
case accept(BLEAnnouncePreflightAcceptance)
|
||||||
|
case reject(BLEAnnouncePreflightRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightPolicy {
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
now: Date
|
||||||
|
) -> BLEAnnouncePreflightDecision {
|
||||||
|
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||||
|
return .reject(.malformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||||
|
guard derivedPeerID == peerID else {
|
||||||
|
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard peerID != localPeerID else {
|
||||||
|
return .reject(.selfAnnounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
||||||
|
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .accept(BLEAnnouncePreflightAcceptance(
|
||||||
|
announcement: announcement,
|
||||||
|
derivedPeerID: derivedPeerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustRejection: Equatable {
|
||||||
|
case missingSignature
|
||||||
|
case invalidSignature
|
||||||
|
case keyMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
|
case verified
|
||||||
|
case reject(BLEAnnounceTrustRejection)
|
||||||
|
|
||||||
|
var isVerified: Bool {
|
||||||
|
self == .verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustPolicy {
|
||||||
|
static func evaluate(
|
||||||
|
hasSignature: Bool,
|
||||||
|
signatureValid: Bool,
|
||||||
|
existingNoisePublicKey: Data?,
|
||||||
|
announcedNoisePublicKey: Data
|
||||||
|
) -> BLEAnnounceTrustDecision {
|
||||||
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
|
return .reject(.keyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hasSignature else {
|
||||||
|
return .reject(.missingSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard signatureValid else {
|
||||||
|
return .reject(.invalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEAnnounceResponsePlan: Equatable {
|
||||||
|
let shouldNotifyPeerConnected: Bool
|
||||||
|
let shouldScheduleInitialSync: Bool
|
||||||
|
let shouldSendAnnounceBack: Bool
|
||||||
|
let shouldScheduleAfterglow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceResponsePolicy {
|
||||||
|
static func plan(
|
||||||
|
isDirectAnnounce: Bool,
|
||||||
|
isNewPeer: Bool,
|
||||||
|
isReconnectedPeer: Bool,
|
||||||
|
shouldSendAnnounceBack: Bool
|
||||||
|
) -> BLEAnnounceResponsePlan {
|
||||||
|
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
||||||
|
|
||||||
|
return BLEAnnounceResponsePlan(
|
||||||
|
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
||||||
|
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
||||||
|
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
||||||
|
shouldScheduleAfterglow: isNewPeer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||