mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 08:25:19 +00:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fc21c23f | ||
|
|
79bd4af912 | ||
|
|
96c78ef5a2 | ||
|
|
01256f3041 | ||
|
|
2edbe2bbbd | ||
|
|
a91978c10e | ||
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
b81ae0b4c0 | ||
|
|
b6d42261d0 | ||
|
|
3d914dcf46 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 | ||
|
|
f5caa1751a | ||
|
|
de906cb97c | ||
|
|
a41ec65f58 |
@@ -7,6 +7,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -17,24 +18,54 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
wget 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: Check for changes
|
- name: Configure git
|
||||||
id: git-check
|
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
git config user.email "action@github.com"
|
||||||
|
git config user.name "GitHub Action"
|
||||||
- name: Commit and push changes
|
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
- name: Create update branch if changes
|
||||||
|
id: create_branch
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
# exit early if no changes
|
||||||
git config --local user.name "GitHub Action"
|
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||||
|
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)"
|
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||||
git push
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
env:
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
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"
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
run: swift build
|
run: swift build
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
run: swift test --parallel
|
||||||
|
|||||||
+5
-4
@@ -9,9 +9,6 @@ plans/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
## User settings
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## 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
|
||||||
*.xccheckout
|
*.xccheckout
|
||||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
|||||||
|
|
||||||
## Xcode project
|
## Xcode project
|
||||||
*.xcodeproj/project.xcworkspace/
|
*.xcodeproj/project.xcworkspace/
|
||||||
*.xcodeproj/xcshareddata/
|
## Xcode User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -68,6 +66,9 @@ __pycache__/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
## Cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.2
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||||
|
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||||
|
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||||
|
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
|
|
||||||
# Backup original files
|
# Backup original files
|
||||||
|
|||||||
+9
-14
@@ -4,6 +4,7 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v16),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
@@ -15,6 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
|
.package(path: "localPackages/Tor"),
|
||||||
.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")
|
||||||
],
|
],
|
||||||
@@ -24,8 +26,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.target(name: "TorC"),
|
.product(name: "Tor", package: "Tor")
|
||||||
.target(name: "tor-nolzma")
|
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -33,21 +34,12 @@ let package = Package(
|
|||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"Services/Tor/C/"
|
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
resources: [
|
||||||
.linkedLibrary("z")
|
.process("Localizable.xcstrings")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "TorC",
|
|
||||||
path: "bitchat/Services/Tor/C"
|
|
||||||
),
|
|
||||||
.binaryTarget(
|
|
||||||
name: "tor-nolzma",
|
|
||||||
path: "Frameworks/tor-nolzma.xcframework"
|
|
||||||
),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: ["bitchat"],
|
||||||
@@ -55,6 +47,9 @@ let package = Package(
|
|||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localization")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -116,3 +116,10 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
Want to try this on macos: `just run` will set it up and run from source.
|
Want to try this on macos: `just run` will set it up and run from source.
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||||
|
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||||
|
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||||
|
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||||
|
|||||||
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
|
|||||||
|
|
||||||
**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.
|
**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`)
|
### 6.2. Application Message Format (`BitchatMessage`)
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||||
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
|
|||||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
| 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. |
|
| 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
|
## 7. Message Routing and Propagation
|
||||||
|
|||||||
Generated
+45
-21
@@ -7,15 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
|
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 */
|
||||||
@@ -58,11 +57,10 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
|
||||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -87,7 +85,6 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
LaunchScreen.storyboard,
|
LaunchScreen.storyboard,
|
||||||
Services/Tor/C/include/.gitkeep,
|
|
||||||
);
|
);
|
||||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||||
};
|
};
|
||||||
@@ -136,32 +133,22 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
|
A6E3EA812E7706A80032EA8A /* Tor 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 */,
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
|
||||||
);
|
|
||||||
path = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
18198ED912AAF495D8AF7763 = {
|
18198ED912AAF495D8AF7763 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -170,8 +157,8 @@
|
|||||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||||
A6E367C92E76469E0032EA8A /* Configs */,
|
A6E367C92E76469E0032EA8A /* Configs */,
|
||||||
0481A3542E6D877600FC845E /* Frameworks */,
|
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -187,6 +174,14 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -207,6 +202,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -284,6 +280,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -304,12 +301,27 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
Base,
|
Base,
|
||||||
en,
|
en,
|
||||||
|
es,
|
||||||
|
ar,
|
||||||
|
de,
|
||||||
|
fr,
|
||||||
|
he,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ne,
|
||||||
|
"pt-BR",
|
||||||
|
ru,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
mainGroup = 18198ED912AAF495D8AF7763;
|
mainGroup = 18198ED912AAF495D8AF7763;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -867,6 +879,10 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Tor;
|
||||||
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
@@ -894,6 +910,14 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitLogger;
|
productName = BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||||
|
BuildableName = "bitchatShareExtension.appex"
|
||||||
|
BlueprintName = "bitchatShareExtension"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
codeCoverageEnabled = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<CodeCoverageTargets>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</CodeCoverageTargets>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||||
|
BuildableName = "bitchatTests_iOS.xctest"
|
||||||
|
BlueprintName = "bitchatTests_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
|
value = "debug"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||||
|
BuildableName = "bitchatTests_macOS.xctest"
|
||||||
|
BlueprintName = "bitchatTests_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
|
value = "debug"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 1.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 2.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
wrappedValue: ChatViewModel(
|
wrappedValue: ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -49,14 +54,12 @@ struct BitchatApp: App {
|
|||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#elseif os(macOS)
|
|
||||||
appDelegate.chatViewModel = chatViewModel
|
|
||||||
#endif
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
NetworkActivationService.shared.start()
|
NetworkActivationService.shared.start()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
@@ -184,6 +187,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
chatViewModel?.applicationWillTerminate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -216,7 +223,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +248,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum ImageUtilsError: Error {
|
||||||
|
case invalidImage
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImageUtils {
|
||||||
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
|
||||||
|
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
// Allow up to 10MB source images (will be scaled down)
|
||||||
|
guard fileSize <= 10 * 1024 * 1024 else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
#if os(iOS)
|
||||||
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
|
#else
|
||||||
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
// Scale the image first
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
|
||||||
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
|
guard let cgImage = scaled.cgImage else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress to target size
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
|
||||||
|
// Draw into a new context to get a clean CGImage without metadata
|
||||||
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return rendered ?? image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
let width = inputCG.width
|
||||||
|
let height = inputCG.height
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
let scaledImage = NSImage(size: newSize)
|
||||||
|
scaledImage.lockFocus()
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||||
|
from: NSRect(origin: .zero, size: size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
scaledImage.unlockFocus()
|
||||||
|
return scaledImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private static func makeOutputURL() throws -> URL {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||||
|
|
||||||
|
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func applicationFilesDirectory() throws -> URL {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
return base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||||
|
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||||
|
@Published private(set) var isPlaying: Bool = false
|
||||||
|
@Published private(set) var currentTime: TimeInterval = 0
|
||||||
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
|
private var player: AVAudioPlayer?
|
||||||
|
private var timer: Timer?
|
||||||
|
private var url: URL
|
||||||
|
|
||||||
|
init(url: URL) {
|
||||||
|
self.url = url
|
||||||
|
super.init()
|
||||||
|
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDuration() {
|
||||||
|
guard duration == 0 else { return }
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||||
|
let loadedDuration = player.duration
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self, self.duration == 0 else { return }
|
||||||
|
self.duration = loadedDuration
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceURL(_ url: URL) {
|
||||||
|
guard url != self.url else { return }
|
||||||
|
stop()
|
||||||
|
self.url = url
|
||||||
|
player = nil
|
||||||
|
duration = 0
|
||||||
|
// Duration will be loaded on demand when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayback() {
|
||||||
|
isPlaying ? pause() : play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
guard ensurePlayerReady() else { return }
|
||||||
|
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||||
|
player?.play()
|
||||||
|
startTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player?.pause()
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
player?.stop()
|
||||||
|
player?.currentTime = 0
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to fraction: Double) {
|
||||||
|
guard ensurePlayerReady() else { return }
|
||||||
|
let clamped = max(0, min(1, fraction))
|
||||||
|
if let player = player {
|
||||||
|
player.currentTime = clamped * player.duration
|
||||||
|
if isPlaying {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVAudioPlayerDelegate
|
||||||
|
|
||||||
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||||
|
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopTimer()
|
||||||
|
self.updateProgress()
|
||||||
|
self.isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func preparePlayer(for url: URL) {
|
||||||
|
// Prepare player synchronously (only called when playback is requested)
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
|
player.delegate = self
|
||||||
|
player.prepareToPlay()
|
||||||
|
self.player = player
|
||||||
|
duration = player.duration
|
||||||
|
currentTime = player.currentTime
|
||||||
|
progress = duration > 0 ? currentTime / duration : 0
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
|
player = nil
|
||||||
|
duration = 0
|
||||||
|
currentTime = 0
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensurePlayerReady() -> Bool {
|
||||||
|
if player == nil {
|
||||||
|
preparePlayer(for: url)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return player != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
if timer != nil { return }
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
|
self?.updateProgress()
|
||||||
|
}
|
||||||
|
if let timer = timer {
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress() {
|
||||||
|
guard let player = player else {
|
||||||
|
currentTime = 0
|
||||||
|
duration = 0
|
||||||
|
progress = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentTime = player.currentTime
|
||||||
|
duration = player.duration
|
||||||
|
progress = duration > 0 ? currentTime / duration : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures only one voice note plays at a time.
|
||||||
|
final class VoiceNotePlaybackCoordinator {
|
||||||
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
|
private weak var activeController: VoiceNotePlaybackController?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func activate(_ controller: VoiceNotePlaybackController) {
|
||||||
|
if activeController === controller {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeController?.pause()
|
||||||
|
activeController = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||||
|
if activeController === controller {
|
||||||
|
activeController = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
|
enum RecorderError: Error {
|
||||||
|
case microphoneAccessDenied
|
||||||
|
case recorderInitializationFailed
|
||||||
|
case recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
|
private let maxRecordingDuration: TimeInterval = 120
|
||||||
|
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var stopWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permissions
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
func startRecording() throws -> URL {
|
||||||
|
try queue.sync {
|
||||||
|
if recorder?.isRecording == true {
|
||||||
|
throw RecorderError.recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||||
|
completion(self?.currentURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
recorder.stop()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRecording() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopWorkItem?.cancel()
|
||||||
|
self.stopWorkItem = nil
|
||||||
|
if let recorder = self.recorder, recorder.isRecording {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
self.cleanupSession()
|
||||||
|
if let url = self.currentURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metering
|
||||||
|
|
||||||
|
func currentAveragePower() -> Float {
|
||||||
|
queue.sync {
|
||||||
|
recorder?.updateMeters()
|
||||||
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeOutputURL() throws -> URL {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||||
|
|
||||||
|
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return baseDirectory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applicationFilesDirectory() throws -> URL {
|
||||||
|
#if os(iOS)
|
||||||
|
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
#else
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
return base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupSession() {
|
||||||
|
#if os(iOS)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||||
|
final class WaveformCache {
|
||||||
|
static let shared = WaveformCache()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||||
|
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||||
|
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func cachedWaveform(for url: URL) -> [Float]? {
|
||||||
|
queue.sync {
|
||||||
|
guard let entry = cache[url] else { return nil }
|
||||||
|
return entry.waveform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Check cache (read-only, no update needed on cache hit for performance)
|
||||||
|
if let entry = self.cache[url] {
|
||||||
|
DispatchQueue.main.async { completion(entry.waveform) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||||
|
DispatchQueue.main.async { completion([]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Evict oldest entry if cache is full
|
||||||
|
if self.cache.count >= self.maxCacheSize {
|
||||||
|
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||||
|
self.cache.removeValue(forKey: oldest.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache[url] = (computed, Date())
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { completion(computed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purge(url: URL) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeAll() {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
|
guard bins > 0 else { return nil }
|
||||||
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
return autoreleasepool {
|
||||||
|
do {
|
||||||
|
let audioFile = try AVAudioFile(forReading: url)
|
||||||
|
let length = Int(audioFile.length)
|
||||||
|
guard length > 0 else { return nil }
|
||||||
|
|
||||||
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||||
|
guard let channelData = buffer.floatChannelData else { return nil }
|
||||||
|
|
||||||
|
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
let samplesPerBin = max(1, frameLength / bins)
|
||||||
|
|
||||||
|
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||||
|
for bin in 0..<bins {
|
||||||
|
let start = bin * samplesPerBin
|
||||||
|
let end = min(frameLength, start + samplesPerBin)
|
||||||
|
if start >= end { break }
|
||||||
|
|
||||||
|
var sum: Float = 0
|
||||||
|
var sampleCount = 0
|
||||||
|
for frame in start..<end {
|
||||||
|
var sampleValue: Float = 0
|
||||||
|
for channel in 0..<channelCount {
|
||||||
|
sampleValue += fabsf(channelData[channel][frame])
|
||||||
|
}
|
||||||
|
sum += sampleValue / Float(channelCount)
|
||||||
|
sampleCount += 1
|
||||||
|
}
|
||||||
|
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||||
|
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||||
|
}
|
||||||
|
return magnitudes
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ import Foundation
|
|||||||
/// 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: String // 8 random bytes
|
let peerID: PeerID // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Cryptographic Identities
|
// MARK: Cryptographic Identities
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func getBlockedNostrPubkeys() -> Set<String>
|
func getBlockedNostrPubkeys() -> Set<String>
|
||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: String)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Verification
|
// MARK: Verification
|
||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
private var cache: IdentityCache = IdentityCache()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
guard peerID.isShort else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, 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(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: String) {
|
func removeEphemeralSession(peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
<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>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
|||||||
let originalSender: String?
|
let originalSender: String?
|
||||||
let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeerID: String?
|
let senderPeerID: PeerID?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
@@ -42,7 +42,19 @@ final class BitchatMessage: Codable {
|
|||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
init(
|
||||||
|
id: String? = nil,
|
||||||
|
sender: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isRelay: Bool,
|
||||||
|
originalSender: String? = nil,
|
||||||
|
isPrivate: Bool = false,
|
||||||
|
recipientNickname: String? = nil,
|
||||||
|
senderPeerID: PeerID? = nil,
|
||||||
|
mentions: [String]? = nil,
|
||||||
|
deliveryStatus: DeliveryStatus? = nil
|
||||||
|
) {
|
||||||
self.id = id ?? UUID().uuidString
|
self.id = id ?? UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -151,7 +163,7 @@ extension BitchatMessage {
|
|||||||
data.append(recipData.prefix(255))
|
data.append(recipData.prefix(255))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||||
data.append(UInt8(min(peerData.count, 255)))
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
data.append(peerData.prefix(255))
|
data.append(peerData.prefix(255))
|
||||||
}
|
}
|
||||||
@@ -264,11 +276,11 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var senderPeerID: String?
|
var senderPeerID: PeerID?
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
if offset + length <= dataCopy.count {
|
if offset + length <= dataCopy.count {
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||||
offset += length
|
offset += length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ struct BitchatPacket: Codable {
|
|||||||
let payload: Data
|
let payload: Data
|
||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
|
var route: [Data]?
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
|
||||||
self.version = 1
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
self.recipientID = recipientID
|
self.recipientID = recipientID
|
||||||
@@ -31,15 +32,16 @@ struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = signature
|
self.signature = signature
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
|
self.route = route
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
var senderData = Data()
|
var senderData = Data()
|
||||||
var tempID = senderID
|
var tempID = senderID.id
|
||||||
while tempID.count >= 2 {
|
while tempID.count >= 2 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -53,6 +55,7 @@ struct BitchatPacket: Codable {
|
|||||||
self.payload = payload
|
self.payload = payload
|
||||||
self.signature = nil
|
self.signature = nil
|
||||||
self.ttl = ttl
|
self.ttl = ttl
|
||||||
|
self.route = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var data: Data? {
|
var data: Data? {
|
||||||
@@ -80,7 +83,9 @@ struct BitchatPacket: Codable {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
version: version,
|
||||||
|
route: route
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Foundation
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Identifiable, Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
let id: String // 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 lastSeen: Date
|
||||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIcon: String {
|
var statusIcon: String {
|
||||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Initialize from mesh service data
|
// Initialize from mesh service data
|
||||||
init(
|
init(
|
||||||
id: String,
|
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.id = id
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||||
lhs.id == rhs.id
|
lhs.peerID == rhs.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// CommandsInfo.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - CommandInfo Enum
|
||||||
|
|
||||||
|
enum CommandInfo: String, Identifiable {
|
||||||
|
case block
|
||||||
|
case clear
|
||||||
|
case hug
|
||||||
|
case message = "dm"
|
||||||
|
case slap
|
||||||
|
case unblock
|
||||||
|
case who
|
||||||
|
case favorite
|
||||||
|
case unfavorite
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var alias: String { "/" + rawValue }
|
||||||
|
|
||||||
|
var placeholder: String? {
|
||||||
|
switch self {
|
||||||
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||||
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
|
case .clear, .who:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .block: String(localized: "content.commands.block")
|
||||||
|
case .clear: String(localized: "content.commands.clear")
|
||||||
|
case .hug: String(localized: "content.commands.hug")
|
||||||
|
case .message: String(localized: "content.commands.message")
|
||||||
|
case .slap: String(localized: "content.commands.slap")
|
||||||
|
case .unblock: String(localized: "content.commands.unblock")
|
||||||
|
case .who: String(localized: "content.commands.who")
|
||||||
|
case .favorite: String(localized: "content.commands.favorite")
|
||||||
|
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
|
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||||
|
if isGeoPublic || isGeoDM {
|
||||||
|
return baseCommands + [.favorite, .unfavorite]
|
||||||
|
}
|
||||||
|
return baseCommands
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//
|
||||||
|
// PeerID.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PeerID: Equatable, Hashable {
|
||||||
|
enum Prefix: String, CaseIterable {
|
||||||
|
/// When no prefix is provided
|
||||||
|
case empty = ""
|
||||||
|
/// `"mesh:"`
|
||||||
|
case mesh = "mesh:"
|
||||||
|
/// `"name:"`
|
||||||
|
case name = "name:"
|
||||||
|
/// `"noise:"` (+ 64 characters hex)
|
||||||
|
case noise = "noise:"
|
||||||
|
/// `"nostr_"` (+ 16 characters hex)
|
||||||
|
case geoDM = "nostr_"
|
||||||
|
/// `"nostr:"` (+ 8 characters hex)
|
||||||
|
case geoChat = "nostr:"
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix: Prefix
|
||||||
|
|
||||||
|
/// Returns the actual value without any prefix
|
||||||
|
let bare: String
|
||||||
|
|
||||||
|
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||||
|
var id: String { prefix.rawValue + bare }
|
||||||
|
|
||||||
|
// Private so the callers have to go through a convenience init
|
||||||
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
|
self.prefix = prefix
|
||||||
|
self.bare = String(bare).lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||||
|
init(nostr_ pubKey: String) {
|
||||||
|
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||||
|
init(nostr pubKey: String) {
|
||||||
|
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||||
|
init(str: any StringProtocol) {
|
||||||
|
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||||
|
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||||
|
} else {
|
||||||
|
self.init(prefix: .empty, bare: str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to handle `Optional<String>`
|
||||||
|
init?(str: (any StringProtocol)?) {
|
||||||
|
guard let str else { return nil }
|
||||||
|
self.init(str: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID by converting Data to String
|
||||||
|
init?(data: Data) {
|
||||||
|
self.init(str: String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
|
init(hexData: Data) {
|
||||||
|
self.init(str: hexData.hexEncodedString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
|
init?(hexData: Data?) {
|
||||||
|
guard let hexData else { return nil }
|
||||||
|
self.init(hexData: hexData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
|
init(publicKey: Data) {
|
||||||
|
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||||
|
func toShort() -> PeerID {
|
||||||
|
if let noiseKey {
|
||||||
|
return PeerID(publicKey: noiseKey)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
extension PeerID: Codable {
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
var isEmpty: Bool {
|
||||||
|
id.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr:`"
|
||||||
|
var isGeoChat: Bool {
|
||||||
|
prefix == .geoChat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr_`"
|
||||||
|
var isGeoDM: Bool {
|
||||||
|
prefix == .geoDM
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPercentEncoded() -> String {
|
||||||
|
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
var routingData: Data? {
|
||||||
|
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
||||||
|
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
||||||
|
let short = toShort()
|
||||||
|
return Data(hexString: short.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(routingData: Data) {
|
||||||
|
guard routingData.count == 8 else { return nil }
|
||||||
|
self.init(hexData: routingData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
|
var isValid: Bool {
|
||||||
|
if prefix != .empty {
|
||||||
|
return PeerID(str: bare).isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||||
|
if isShort || isNoiseKeyHex {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If length equals short or full but isn't valid hex, reject
|
||||||
|
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||||
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
|
return !id.isEmpty &&
|
||||||
|
id.count < Constants.maxIDLength &&
|
||||||
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the `bare` id is all hex
|
||||||
|
var isHex: Bool {
|
||||||
|
bare.allSatisfy { $0.isHexDigit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short routing IDs (exact 16-hex)
|
||||||
|
var isShort: Bool {
|
||||||
|
bare.count == Constants.hexIDLength && isHex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
var isNoiseKeyHex: Bool {
|
||||||
|
noiseKey != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key (exact 64-hex) as Data
|
||||||
|
var noiseKey: Data? {
|
||||||
|
guard bare.count == Constants.maxIDLength else { return nil }
|
||||||
|
return Data(hexString: bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comparable
|
||||||
|
|
||||||
|
extension PeerID: Comparable {
|
||||||
|
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||||
|
lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CustomStringConvertible
|
||||||
|
|
||||||
|
extension PeerID: CustomStringConvertible {
|
||||||
|
/// So it returns the actual `id` like before even inside another String
|
||||||
|
var description: String {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,11 @@ import Foundation
|
|||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
let receiptID: String
|
let receiptID: String
|
||||||
var readerID: String // Who read it
|
var readerID: PeerID // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = UUID().uuidString
|
self.receiptID = UUID().uuidString
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For binary decoding
|
// For binary decoding
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = receiptID
|
self.receiptID = receiptID
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
|||||||
data.appendUUID(receiptID)
|
data.appendUUID(receiptID)
|
||||||
// ReaderID as 8-byte hex string
|
// ReaderID as 8-byte hex string
|
||||||
var readerData = Data()
|
var readerData = Data()
|
||||||
var tempID = readerID
|
var tempID = readerID.id
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
|||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
let readerID = PeerID(hexData: readerIDData)
|
||||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
guard readerID.isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ struct RequestSyncPacket {
|
|||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
|
let types: SyncTypeFlags?
|
||||||
|
|
||||||
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
|
||||||
|
self.p = p
|
||||||
|
self.m = m
|
||||||
|
self.data = data
|
||||||
|
self.types = types
|
||||||
|
}
|
||||||
|
|
||||||
func encode() -> Data {
|
func encode() -> Data {
|
||||||
var out = Data()
|
var out = Data()
|
||||||
@@ -25,6 +33,9 @@ struct RequestSyncPacket {
|
|||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
// data
|
// data
|
||||||
putTLV(0x03, data)
|
putTLV(0x03, data)
|
||||||
|
if let typesData = types?.toData() {
|
||||||
|
putTLV(0x04, typesData)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +44,7 @@ struct RequestSyncPacket {
|
|||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
|
var types: SyncTypeFlags? = nil
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -52,12 +64,16 @@ struct RequestSyncPacket {
|
|||||||
case 0x03:
|
case 0x03:
|
||||||
if v.count > maxAcceptBytes { return nil }
|
if v.count > maxAcceptBytes { return nil }
|
||||||
payload = v
|
payload = v
|
||||||
|
case 0x04:
|
||||||
|
if let decoded = SyncTypeFlags.decode(v) {
|
||||||
|
types = decoded
|
||||||
|
}
|
||||||
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, mm > 0 else { return nil }
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
|||||||
if nameData.count <= 32 {
|
if nameData.count <= 32 {
|
||||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||||
} else {
|
} else {
|
||||||
self.hash = Data(SHA256.hash(data: nameData))
|
self.hash = nameData.sha256Hash()
|
||||||
}
|
}
|
||||||
self.chainingKey = self.hash
|
self.chainingKey = self.hash
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mixHash(_ data: Data) {
|
func mixHash(_ data: Data) {
|
||||||
hash = Data(SHA256.hash(data: hash + data))
|
hash = (hash + data).sha256Hash()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||||
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
|
|||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
|
|
||||||
default:
|
case .e, .s:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// NoiseRateLimiter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseRateLimiter {
|
||||||
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
|
// Global rate limiting
|
||||||
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new handshake
|
||||||
|
timestamps.append(now)
|
||||||
|
handshakeTimestamps[peerID] = timestamps
|
||||||
|
globalHandshakeTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||||
|
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = messageTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new message
|
||||||
|
timestamps.append(now)
|
||||||
|
messageTimestamps[peerID] = timestamps
|
||||||
|
globalMessageTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(for peerID: PeerID) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAll() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeAll()
|
||||||
|
self.messageTimestamps.removeAll()
|
||||||
|
self.globalHandshakeTimestamps.removeAll()
|
||||||
|
self.globalMessageTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
//
|
|
||||||
// NoiseSecurityConsiderations.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
|
||||||
// Maximum message size to prevent memory exhaustion
|
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
|
||||||
|
|
||||||
// Maximum handshake message size
|
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
|
||||||
|
|
||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
static let maxHandshakesPerMinute = 10
|
|
||||||
static let maxMessagesPerSecond = 100
|
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
|
||||||
static let maxGlobalHandshakesPerMinute = 30
|
|
||||||
static let maxGlobalMessagesPerSecond = 500
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Validations
|
|
||||||
|
|
||||||
struct NoiseSecurityValidator {
|
|
||||||
|
|
||||||
/// Validate message size
|
|
||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate handshake message size
|
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate peer ID format using unified validator
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
return InputValidator.validatePeerID(peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
|
||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
|
||||||
private(set) var messageCount: UInt64 = 0
|
|
||||||
private let sessionStartTime = Date()
|
|
||||||
private(set) var lastActivityTime = Date()
|
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check message count
|
|
||||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
|
||||||
throw NoiseSecurityError.sessionExhausted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let encrypted = try super.encrypt(plaintext)
|
|
||||||
messageCount += 1
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let decrypted = try super.decrypt(ciphertext)
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsRenegotiation() -> Bool {
|
|
||||||
// Check if we've used more than 90% of message limit
|
|
||||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
|
||||||
if messageCount >= messageThreshold {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if last activity was more than 30 minutes ago
|
|
||||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Testing Support
|
|
||||||
#if DEBUG
|
|
||||||
func setLastActivityTimeForTesting(_ date: Date) {
|
|
||||||
lastActivityTime = date
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMessageCountForTesting(_ count: UInt64) {
|
|
||||||
messageCount = count
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Rate Limiter
|
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
|
||||||
|
|
||||||
// Global rate limiting
|
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
|
||||||
private var globalMessageTimestamps: [Date] = []
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
|
||||||
|
|
||||||
func allowHandshake(from peerID: String) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new handshake
|
|
||||||
timestamps.append(now)
|
|
||||||
handshakeTimestamps[peerID] = timestamps
|
|
||||||
globalHandshakeTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allowMessage(from peerID: String) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
|
||||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = messageTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new message
|
|
||||||
timestamps.append(now)
|
|
||||||
messageTimestamps[peerID] = timestamps
|
|
||||||
globalMessageTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(for peerID: String) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetAll() {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeAll()
|
|
||||||
self.messageTimestamps.removeAll()
|
|
||||||
self.globalHandshakeTimestamps.removeAll()
|
|
||||||
self.globalMessageTimestamps.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Errors
|
|
||||||
|
|
||||||
enum NoiseSecurityError: Error {
|
|
||||||
case sessionExpired
|
|
||||||
case sessionExhausted
|
|
||||||
case messageTooLarge
|
|
||||||
case invalidPeerID
|
|
||||||
case rateLimitExceeded
|
|
||||||
case handshakeTimeout
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityConstants.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityConstants {
|
||||||
|
// Maximum message size to prevent memory exhaustion
|
||||||
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
|
// Maximum handshake message size
|
||||||
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
|
// Handshake timeout - abandon incomplete handshakes
|
||||||
|
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||||
|
|
||||||
|
// Maximum concurrent sessions per peer
|
||||||
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
static let maxHandshakesPerMinute = 10
|
||||||
|
static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
|
// Global rate limiting (across all peers)
|
||||||
|
static let maxGlobalHandshakesPerMinute = 30
|
||||||
|
static let maxGlobalMessagesPerSecond = 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityError: Error {
|
||||||
|
case sessionExpired
|
||||||
|
case sessionExhausted
|
||||||
|
case messageTooLarge
|
||||||
|
case invalidPeerID
|
||||||
|
case rateLimitExceeded
|
||||||
|
case handshakeTimeout
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityValidator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NoiseSecurityValidator {
|
||||||
|
|
||||||
|
/// Validate message size
|
||||||
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate handshake message size
|
||||||
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,32 +10,8 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Noise Session State
|
|
||||||
|
|
||||||
enum NoiseSessionState: Equatable {
|
|
||||||
case uninitialized
|
|
||||||
case handshaking
|
|
||||||
case established
|
|
||||||
case failed(Error)
|
|
||||||
|
|
||||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.uninitialized, .uninitialized),
|
|
||||||
(.handshaking, .handshaking),
|
|
||||||
(.established, .established):
|
|
||||||
return true
|
|
||||||
case (.failed, .failed):
|
|
||||||
return true // We don't compare the errors
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Noise Session
|
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private var state: NoiseSessionState = .uninitialized
|
private var state: NoiseSessionState = .uninitialized
|
||||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
|||||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||||
|
|
||||||
init(
|
init(
|
||||||
peerID: String,
|
peerID: PeerID,
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
|||||||
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))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
|||||||
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))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -220,12 +196,6 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeHash() -> Data? {
|
|
||||||
return sessionQueue.sync {
|
|
||||||
return handshakeHash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
sessionQueue.sync(flags: .barrier) {
|
sessionQueue.sync(flags: .barrier) {
|
||||||
let wasEstablished = state == .established
|
let wasEstablished = state == .established
|
||||||
@@ -252,249 +222,8 @@ class NoiseSession {
|
|||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
if wasEstablished {
|
if wasEstablished {
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Manager
|
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
|
||||||
private var sessions: [String: NoiseSession] = [:]
|
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
|
||||||
var onSessionFailed: ((String, Error) -> Void)?
|
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
|
||||||
|
|
||||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
|
||||||
return managerQueue.sync(flags: .barrier) {
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSession(for peerID: String) -> NoiseSession? {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions[peerID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeSession(for peerID: String) {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
if let session = sessions[peerID] {
|
|
||||||
if session.isEstablished() {
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
// Clear sensitive data before removing
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAllSessions() {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
for (_, session) in sessions {
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
sessions.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions.filter { $0.value.isEstablished() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Handshake Helpers
|
|
||||||
|
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
|
||||||
// Check if we already have an established session
|
|
||||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
|
||||||
// Session already established, don't recreate
|
|
||||||
throw NoiseSessionError.alreadyEstablished
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any existing non-established session
|
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new initiator session
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
|
|
||||||
do {
|
|
||||||
let handshakeData = try session.startHandshake()
|
|
||||||
return handshakeData
|
|
||||||
} catch {
|
|
||||||
// Clean up failed session
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
|
||||||
var shouldCreateNew = false
|
|
||||||
var existingSession: NoiseSession? = nil
|
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
|
||||||
// If we have an established session, the peer must have cleared their session
|
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
|
||||||
// We should accept the new handshake to re-establish encryption
|
|
||||||
if existing.isEstablished() {
|
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
|
||||||
// reset and start fresh (the other side may have restarted)
|
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
existingSession = existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: .responder,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = newSession
|
|
||||||
session = newSession
|
|
||||||
} else {
|
|
||||||
session = existingSession!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
|
||||||
do {
|
|
||||||
let response = try session.processHandshakeMessage(message)
|
|
||||||
|
|
||||||
// Check if session is established after processing
|
|
||||||
if session.isEstablished() {
|
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch {
|
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionFailed?(peerID, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
|
||||||
guard let session = getSession(for: peerID) else {
|
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return try session.encrypt(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
|
||||||
guard let session = getSession(for: peerID) else {
|
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return try session.decrypt(ciphertext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Management
|
|
||||||
|
|
||||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
|
||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHandshakeHash(for peerID: String) -> Data? {
|
|
||||||
return getSession(for: peerID)?.getHandshakeHash()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
|
||||||
|
|
||||||
for (peerID, session) in sessions {
|
|
||||||
if let secureSession = session as? SecureNoiseSession,
|
|
||||||
secureSession.isEstablished(),
|
|
||||||
secureSession.needsRenegotiation() {
|
|
||||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return needingRekey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initiateRekey(for peerID: String) throws {
|
|
||||||
// Remove old session
|
|
||||||
removeSession(for: peerID)
|
|
||||||
|
|
||||||
// Initiate new handshake
|
|
||||||
_ = try initiateHandshake(with: peerID)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
enum NoiseSessionError: Error {
|
|
||||||
case invalidState
|
|
||||||
case notEstablished
|
|
||||||
case sessionNotFound
|
|
||||||
case handshakeFailed(Error)
|
|
||||||
case alreadyEstablished
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionError: Error, Equatable {
|
||||||
|
case invalidState
|
||||||
|
case notEstablished
|
||||||
|
case sessionNotFound
|
||||||
|
case alreadyEstablished
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionManager.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseSessionManager {
|
||||||
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSession(for peerID: PeerID) {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
|
session.reset() // Clear sensitive data before removing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllSessions() {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
for (_, session) in sessions {
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
sessions.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
// Check if we already have an established session
|
||||||
|
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||||
|
// Session already established, don't recreate
|
||||||
|
throw NoiseSessionError.alreadyEstablished
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing non-established session
|
||||||
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new initiator session
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
|
||||||
|
do {
|
||||||
|
let handshakeData = try session.startHandshake()
|
||||||
|
return handshakeData
|
||||||
|
} catch {
|
||||||
|
// Clean up failed session
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
var shouldCreateNew = false
|
||||||
|
var existingSession: NoiseSession? = nil
|
||||||
|
|
||||||
|
if let existing = sessions[peerID] {
|
||||||
|
// If we have an established session, the peer must have cleared their session
|
||||||
|
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||||
|
// We should accept the new handshake to re-establish encryption
|
||||||
|
if existing.isEstablished() {
|
||||||
|
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
// If we're in the middle of a handshake and receive a new initiation,
|
||||||
|
// reset and start fresh (the other side may have restarted)
|
||||||
|
if existing.getState() == .handshaking && message.count == 32 {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
existingSession = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldCreateNew = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create session
|
||||||
|
let session: NoiseSession
|
||||||
|
if shouldCreateNew {
|
||||||
|
let newSession = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = newSession
|
||||||
|
session = newSession
|
||||||
|
} else {
|
||||||
|
session = existingSession!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the handshake message within the synchronized block
|
||||||
|
do {
|
||||||
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
|
// Check if session is established after processing
|
||||||
|
if session.isEstablished() {
|
||||||
|
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch {
|
||||||
|
// Reset the session on handshake failure so next attempt can start fresh
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionFailed?(peerID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Management
|
||||||
|
|
||||||
|
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
|
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||||
|
|
||||||
|
for (peerID, session) in sessions {
|
||||||
|
if let secureSession = session as? SecureNoiseSession,
|
||||||
|
secureSession.isEstablished(),
|
||||||
|
secureSession.needsRenegotiation() {
|
||||||
|
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needingRekey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initiateRekey(for peerID: PeerID) throws {
|
||||||
|
// Remove old session
|
||||||
|
removeSession(for: peerID)
|
||||||
|
|
||||||
|
// Initiate new handshake
|
||||||
|
_ = try initiateHandshake(with: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionState.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionState: Equatable {
|
||||||
|
case uninitialized
|
||||||
|
case handshaking
|
||||||
|
case established
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// SecureNoiseSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class SecureNoiseSession: NoiseSession {
|
||||||
|
private(set) var messageCount: UInt64 = 0
|
||||||
|
private let sessionStartTime = Date()
|
||||||
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message count
|
||||||
|
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||||
|
throw NoiseSecurityError.sessionExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = try super.encrypt(plaintext)
|
||||||
|
messageCount += 1
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try super.decrypt(ciphertext)
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRenegotiation() -> Bool {
|
||||||
|
// Check if we've used more than 90% of message limit
|
||||||
|
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||||
|
if messageCount >= messageThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if last activity was more than 30 minutes ago
|
||||||
|
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Testing Support
|
||||||
|
#if DEBUG
|
||||||
|
func setLastActivityTimeForTesting(_ date: Date) {
|
||||||
|
lastActivityTime = date
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
|
messageCount = count
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bech32 encoding for Nostr (minimal implementation)
|
||||||
|
enum Bech32 {
|
||||||
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
static func encode(hrp: String, data: Data) throws -> String {
|
||||||
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||||
|
let checksum = createChecksum(hrp: hrp, values: values)
|
||||||
|
let combined = values + checksum
|
||||||
|
|
||||||
|
return hrp + "1" + combined.map {
|
||||||
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
|
return String(charset[index])
|
||||||
|
}.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||||
|
// Find the last occurrence of '1'
|
||||||
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||||
|
throw Bech32Error.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
|
// Validate HRP contains only ASCII characters
|
||||||
|
for char in hrp {
|
||||||
|
guard char.asciiValue != nil else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
|
// Convert characters to values
|
||||||
|
var values = [UInt8]()
|
||||||
|
for char in dataString {
|
||||||
|
guard let index = charset.firstIndex(of: char) else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
guard values.count >= 6 else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadValues = Array(values.dropLast(6))
|
||||||
|
let checksum = Array(values.suffix(6))
|
||||||
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
|
guard checksum == expectedChecksum else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to bytes
|
||||||
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
|
return (hrp: hrp, data: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bech32Error: Error {
|
||||||
|
case invalidFormat
|
||||||
|
case invalidCharacter
|
||||||
|
case invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
var result = [UInt8]()
|
||||||
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
|
for value in data {
|
||||||
|
acc = (acc << from) | Int(value)
|
||||||
|
bits += from
|
||||||
|
|
||||||
|
while bits >= to {
|
||||||
|
bits -= to
|
||||||
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pad && bits > 0 {
|
||||||
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
|
for i in 0..<6 {
|
||||||
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
|
var result = [UInt8]()
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
|
}
|
||||||
|
result.append(0)
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
|
var chk = 1
|
||||||
|
for value in values {
|
||||||
|
let b = chk >> 25
|
||||||
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||||
|
for i in 0..<5 {
|
||||||
|
if (b >> i) & 1 == 1 {
|
||||||
|
chk ^= generator[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// 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.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -11,19 +17,32 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 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 remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||||
|
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private var retryTask: Task<Void, Never>?
|
||||||
|
private var retryAttempt: Int = 0
|
||||||
|
private var isFetching: Bool = false
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load cached or bundled data synchronously
|
entries = loadLocalEntries()
|
||||||
self.entries = self.loadLocalEntries()
|
registerObservers()
|
||||||
// Fire-and-forget remote refresh if stale
|
startRefreshTimer()
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
retryTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
let center = Geohash.decodeCenter(geohash)
|
let center = Geohash.decodeCenter(geohash)
|
||||||
@@ -32,52 +51,148 @@ 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.
|
||||||
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 else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
let sorted = entries
|
|
||||||
.sorted { a, b in
|
if entries.count <= count {
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
return entries
|
||||||
|
.sorted { a, b in
|
||||||
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
|
}
|
||||||
|
.map { "wss://\($0.host)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var best: [(entry: Entry, distance: Double)] = []
|
||||||
|
best.reserveCapacity(count)
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||||
|
if best.count < count {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
best.removeLast()
|
||||||
}
|
}
|
||||||
.prefix(count)
|
}
|
||||||
return sorted.map { "wss://\($0.host)" }
|
|
||||||
|
return best.map { "wss://\($0.entry.host)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded() {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
|
||||||
|
if !force {
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
|
} else if last != .distantPast,
|
||||||
|
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||||
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRetry()
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
guard !isFetching else { return }
|
||||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
isFetching = true
|
||||||
Task.detached {
|
|
||||||
|
let request = URLRequest(
|
||||||
|
url: remoteURL,
|
||||||
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
|
timeoutInterval: 15
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
if !ready {
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
await self.handleFetchFailure(.torNotReady)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
|
||||||
guard let self = self else { return }
|
do {
|
||||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
if !parsed.isEmpty {
|
await self.handleFetchFailure(.invalidData)
|
||||||
Task { @MainActor in
|
return
|
||||||
self.entries = parsed
|
|
||||||
self.persistCache(text)
|
|
||||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
|
||||||
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
|
guard !parsed.isEmpty else {
|
||||||
|
await self.handleFetchFailure(.invalidData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||||
|
} catch {
|
||||||
|
await self.handleFetchFailure(.network(error))
|
||||||
}
|
}
|
||||||
task.resume()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum FetchFailure {
|
||||||
|
case torNotReady
|
||||||
|
case invalidData
|
||||||
|
case network(Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
|
entries = parsed
|
||||||
|
persistCache(csv)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||||
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
|
isFetching = false
|
||||||
|
retryAttempt = 0
|
||||||
|
cancelRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||||
|
switch reason {
|
||||||
|
case .torNotReady:
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||||
|
case .invalidData:
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||||
|
case .network(let error):
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||||
|
}
|
||||||
|
isFetching = false
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func scheduleRetry() {
|
||||||
|
retryAttempt = min(retryAttempt + 1, 10)
|
||||||
|
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||||
|
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||||
|
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||||
|
let calculated = base * multiplier
|
||||||
|
let delay = min(maxDelay, max(base, calculated))
|
||||||
|
|
||||||
|
cancelRetry()
|
||||||
|
retryTask = Task { [weak self] in
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
await MainActor.run {
|
||||||
|
self?.prefetchIfNeeded(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func cancelRetry() {
|
||||||
|
retryTask?.cancel()
|
||||||
|
retryTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
do {
|
do {
|
||||||
@@ -90,30 +205,35 @@ 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 = self.cacheURL(),
|
if let cache = cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = [
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
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"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
if let data = try? Data(contentsOf: url),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -121,7 +241,6 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
// Skip header if present
|
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.isEmpty { continue }
|
if line.isEmpty { continue }
|
||||||
@@ -142,11 +261,76 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
private func cacheURL() -> URL? {
|
private func cacheURL() -> URL? {
|
||||||
do {
|
do {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
} catch { return nil }
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observers & Timers
|
||||||
|
private func registerObservers() {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
|
||||||
|
let torReady = center.addObserver(
|
||||||
|
forName: .TorDidBecomeReady,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observers.append(torReady)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let didBecomeActive = center.addObserver(
|
||||||
|
forName: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||||
|
guard interval > 0 else { return }
|
||||||
|
|
||||||
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshTimer = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
|
func load(key: String, service: String) -> Data?
|
||||||
|
func delete(key: String, service: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper: KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
return PeerID(publicKey: maybeData)
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
import P256K
|
import P256K
|
||||||
import Security
|
|
||||||
|
|
||||||
// Keychain helper for secure storage
|
|
||||||
struct KeychainHelper {
|
|
||||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
|
||||||
if let accessible = accessible {
|
|
||||||
query[kSecAttrAccessible as String] = accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(key: String, service: String) -> Data? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func delete(key: String, service: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -103,269 +58,3 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
return publicKey.hexEncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge between Noise and Nostr identities
|
|
||||||
struct NostrIdentityBridge {
|
|
||||||
private static let keychainService = "chat.bitchat.nostr"
|
|
||||||
private static let currentIdentityKey = "nostr-current-identity"
|
|
||||||
private static let deviceSeedKey = "nostr-device-seed"
|
|
||||||
// In-memory cache to avoid transient keychain access issues
|
|
||||||
private static var deviceSeedCache: Data?
|
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
|
||||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
||||||
// Check if we already have a Nostr identity
|
|
||||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
|
||||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new Nostr identity
|
|
||||||
let nostrIdentity = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Store it
|
|
||||||
let data = try JSONEncoder().encode(nostrIdentity)
|
|
||||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
|
||||||
|
|
||||||
return nostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
|
||||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
|
||||||
let pubkey = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
|
||||||
static func clearAllAssociations() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
|
||||||
|
|
||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
||||||
/// Stored only on device keychain.
|
|
||||||
private static func getOrCreateDeviceSeed() -> Data {
|
|
||||||
if let cached = deviceSeedCache { return cached }
|
|
||||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
|
||||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = existing
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
var seed = Data(count: 32)
|
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = seed
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
|
||||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateKey(iteration: UInt32) -> Data {
|
|
||||||
var input = Data(msg)
|
|
||||||
var iterBE = iteration.bigEndian
|
|
||||||
withUnsafeBytes(of: &iterBE) { bytes in
|
|
||||||
input.append(contentsOf: bytes)
|
|
||||||
}
|
|
||||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
||||||
return Data(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a few iterations to ensure a valid key can be formed
|
|
||||||
for i in 0..<10 {
|
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As a final fallback, hash the seed+msg and try again
|
|
||||||
var combined = Data()
|
|
||||||
combined.append(seed)
|
|
||||||
combined.append(msg)
|
|
||||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bech32 encoding for Nostr (minimal implementation)
|
|
||||||
enum Bech32 {
|
|
||||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
|
||||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
||||||
let checksum = createChecksum(hrp: hrp, values: values)
|
|
||||||
let combined = values + checksum
|
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
||||||
return String(charset[index])
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
||||||
// Find the last occurrence of '1'
|
|
||||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
||||||
throw Bech32Error.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
|
||||||
for char in hrp {
|
|
||||||
guard char.asciiValue != nil else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
||||||
|
|
||||||
// Convert characters to values
|
|
||||||
var values = [UInt8]()
|
|
||||||
for char in dataString {
|
|
||||||
guard let index = charset.firstIndex(of: char) else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
guard values.count >= 6 else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
|
||||||
let checksum = Array(values.suffix(6))
|
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to bytes
|
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
||||||
return (hrp: hrp, data: Data(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
|
||||||
case invalidFormat
|
|
||||||
case invalidCharacter
|
|
||||||
case invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
||||||
var acc = 0
|
|
||||||
var bits = 0
|
|
||||||
var result = [UInt8]()
|
|
||||||
let maxv = (1 << to) - 1
|
|
||||||
|
|
||||||
for value in data {
|
|
||||||
acc = (acc << from) | Int(value)
|
|
||||||
bits += from
|
|
||||||
|
|
||||||
while bits >= to {
|
|
||||||
bits -= to
|
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pad && bits > 0 {
|
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
|
||||||
var checksum = [UInt8]()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
||||||
var result = [UInt8]()
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue >> 5))
|
|
||||||
}
|
|
||||||
result.append(0)
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue & 31))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
|
||||||
var chk = 1
|
|
||||||
for value in values {
|
|
||||||
let b = chk >> 25
|
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
||||||
for i in 0..<5 {
|
|
||||||
if (b >> i) & 1 == 1 {
|
|
||||||
chk ^= generator[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Bridge between Noise and Nostr identities
|
||||||
|
final class NostrIdentityBridge {
|
||||||
|
private let keychainService = "chat.bitchat.nostr"
|
||||||
|
private let currentIdentityKey = "nostr-current-identity"
|
||||||
|
private let deviceSeedKey = "nostr-device-seed"
|
||||||
|
// In-memory cache to avoid transient keychain access issues
|
||||||
|
private var deviceSeedCache: Data?
|
||||||
|
// Cache derived identities to avoid repeated crypto during view rendering
|
||||||
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||||
|
private let cacheLock = NSLock()
|
||||||
|
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create the current Nostr identity
|
||||||
|
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
|
// Check if we already have a Nostr identity
|
||||||
|
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||||
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Nostr identity
|
||||||
|
let nostrIdentity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
let data = try JSONEncoder().encode(nostrIdentity)
|
||||||
|
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||||
|
|
||||||
|
return nostrIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Nostr public key associated with a Noise public key
|
||||||
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
guard let data = keychain.load(key: key, service: keychainService),
|
||||||
|
let pubkey = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
func clearAllAssociations() {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSeedCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|
||||||
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
|
/// Stored only on device keychain.
|
||||||
|
private func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
|
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
var seed = Data(count: 32)
|
||||||
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||||
|
cacheLock.lock()
|
||||||
|
if let cached = derivedIdentityCache[geohash] {
|
||||||
|
cacheLock.unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheLock.unlock()
|
||||||
|
|
||||||
|
let seed = getOrCreateDeviceSeed()
|
||||||
|
guard let msg = geohash.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(iteration: UInt32) -> Data {
|
||||||
|
var input = Data(msg)
|
||||||
|
var iterBE = iteration.bigEndian
|
||||||
|
withUnsafeBytes(of: &iterBE) { bytes in
|
||||||
|
input.append(contentsOf: bytes)
|
||||||
|
}
|
||||||
|
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||||
|
return Data(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a few iterations to ensure a valid key can be formed
|
||||||
|
for i in 0..<10 {
|
||||||
|
let keyData = candidateKey(iteration: UInt32(i))
|
||||||
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||||
|
// Cache the result
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache[geohash] = identity
|
||||||
|
cacheLock.unlock()
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As a final fallback, hash the seed+msg and try again
|
||||||
|
let fallback = (seed + msg).sha256Hash()
|
||||||
|
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache[geohash] = identity
|
||||||
|
cacheLock.unlock()
|
||||||
|
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
|||||||
] as [Any]
|
] as [Any]
|
||||||
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
let hash = CryptoKit.SHA256.hash(data: data)
|
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||||
let hashData = Data(hash)
|
|
||||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
|
||||||
return (hashHex, hashData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
func jsonString() throws -> String {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -905,6 +906,16 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||||
|
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [1]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["g": geohashes]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ extension Data {
|
|||||||
}
|
}
|
||||||
return self.map { String(format: "%02x", $0) }.joined()
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sha256Hex() -> String {
|
||||||
|
let digest = SHA256.hash(data: self)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
init?(hexString: String) {
|
init?(hexString: String) {
|
||||||
let len = hexString.count / 2
|
let len = hexString.count / 2
|
||||||
@@ -197,7 +203,7 @@ extension Data {
|
|||||||
offset += 16
|
offset += 16
|
||||||
|
|
||||||
// Convert 16 bytes to UUID string format
|
// Convert 16 bytes to UUID string format
|
||||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
let uuid = uuidData.hexEncodedString()
|
||||||
|
|
||||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||||
var result = ""
|
var result = ""
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
///
|
///
|
||||||
/// ## Wire Format
|
/// ## Wire Format
|
||||||
/// ```
|
/// ```
|
||||||
/// Header (Fixed 13 bytes):
|
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
///
|
///
|
||||||
/// Variable sections:
|
/// Variable sections:
|
||||||
/// +----------+-------------+---------+------------+
|
/// +----------+-------------+---------+------------+
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
/// ## Flag Bits
|
/// ## Flag Bits
|
||||||
/// - Bit 0: Has recipient ID (directed message)
|
/// - Bit 0: Has recipient ID (directed message)
|
||||||
/// - Bit 1: Has signature (authenticated message)
|
/// - Bit 1: Has signature (authenticated message)
|
||||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
/// - Bit 2: Is compressed (zlib compression applied)
|
||||||
/// - Bits 3-7: Reserved for future use
|
/// - Bits 3-7: Reserved for future use
|
||||||
///
|
///
|
||||||
/// ## Size Constraints
|
/// ## Size Constraints
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
func trimmingNullBytes() -> Data {
|
func trimmingNullBytes() -> Data {
|
||||||
@@ -105,83 +106,119 @@ extension Data {
|
|||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
static let headerSize = 13
|
static let v1HeaderSize = 14
|
||||||
|
static let v2HeaderSize = 16
|
||||||
static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
// Field offsets within packet header
|
||||||
|
struct Offsets {
|
||||||
|
static let version = 0
|
||||||
|
static let type = 1
|
||||||
|
static let ttl = 2
|
||||||
|
static let timestamp = 3
|
||||||
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
|
switch version {
|
||||||
|
case 1: return v1HeaderSize
|
||||||
|
case 2: return v2HeaderSize
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||||
|
return version == 2 ? 4 : 2
|
||||||
|
}
|
||||||
|
|
||||||
struct Flags {
|
struct Flags {
|
||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
static let isCompressed: UInt8 = 0x04
|
static let isCompressed: UInt8 = 0x04
|
||||||
|
static let hasRoute: UInt8 = 0x08
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
var data = Data()
|
let version = packet.version
|
||||||
|
guard version == 1 || version == 2 else { return nil }
|
||||||
|
|
||||||
// Try to compress payload if beneficial
|
// Try to compress payload when beneficial, keeping original size for later decoding
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
var originalPayloadSize: UInt16? = nil
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
var originalPayloadSize: Int?
|
||||||
if CompressionUtil.shouldCompress(payload) {
|
if CompressionUtil.shouldCompress(payload) {
|
||||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
// Only compress when we can represent the original length in the outbound frame
|
||||||
// Store original size for decompression (2 bytes after payload)
|
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
||||||
originalPayloadSize = UInt16(payload.count)
|
if payload.count <= maxRepresentable,
|
||||||
|
let compressedPayload = CompressionUtil.compress(payload) {
|
||||||
|
originalPayloadSize = payload.count
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
isCompressed = true
|
isCompressed = true
|
||||||
|
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
let originalRoute = packet.route ?? []
|
||||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
|
||||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
let sanitizedRoute: [Data] = originalRoute.map { hop in
|
||||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
if hop.count == senderIDSize { return hop }
|
||||||
data.reserveCapacity(estimated)
|
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
|
||||||
data.append(packet.version)
|
var padded = hop
|
||||||
|
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
guard sanitizedRoute.count <= 255 else { return nil }
|
||||||
|
|
||||||
|
let hasRoute = !sanitizedRoute.isEmpty
|
||||||
|
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
|
||||||
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
|
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
|
||||||
|
|
||||||
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
|
|
||||||
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
|
let estimatedPayload = payloadDataSize
|
||||||
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
||||||
|
|
||||||
|
data.append(version)
|
||||||
data.append(packet.type)
|
data.append(packet.type)
|
||||||
data.append(packet.ttl)
|
data.append(packet.ttl)
|
||||||
|
|
||||||
// Timestamp (8 bytes, big-endian)
|
for shift in stride(from: 56, through: 0, by: -8) {
|
||||||
for i in (0..<8).reversed() {
|
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
||||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
var flags: UInt8 = 0
|
var flags: UInt8 = 0
|
||||||
if packet.recipientID != nil {
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
flags |= Flags.hasRecipient
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
}
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
if packet.signature != nil {
|
if hasRoute { flags |= Flags.hasRoute }
|
||||||
flags |= Flags.hasSignature
|
|
||||||
}
|
|
||||||
if isCompressed {
|
|
||||||
flags |= Flags.isCompressed
|
|
||||||
}
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
if version == 2 {
|
||||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
let length = UInt32(payloadDataSize)
|
||||||
let payloadLength = UInt16(payloadDataSize)
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
} else {
|
||||||
data.append(UInt8(payloadLength & 0xFF))
|
let length = UInt16(payloadDataSize)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
// SenderID (exactly 8 bytes)
|
data.append(UInt8(length & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
data.append(senderBytes)
|
data.append(senderBytes)
|
||||||
if senderBytes.count < senderIDSize {
|
if senderBytes.count < senderIDSize {
|
||||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipientID (if present)
|
|
||||||
if let recipientID = packet.recipientID {
|
if let recipientID = packet.recipientID {
|
||||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||||
data.append(recipientBytes)
|
data.append(recipientBytes)
|
||||||
@@ -189,30 +226,37 @@ struct BinaryProtocol {
|
|||||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload (with original size prepended if compressed)
|
if hasRoute {
|
||||||
|
data.append(UInt8(sanitizedRoute.count))
|
||||||
|
for hop in sanitizedRoute {
|
||||||
|
data.append(hop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
// Prepend original size (2 bytes, big-endian)
|
if version == 2 {
|
||||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
let value = UInt32(originalSize)
|
||||||
data.append(UInt8(originalSize & 0xFF))
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = UInt16(originalSize)
|
||||||
|
data.append(UInt8((value >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value & 0xFF))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
data.append(payload)
|
data.append(payload)
|
||||||
|
|
||||||
// Signature (if present)
|
|
||||||
if let signature = packet.signature {
|
if let signature = packet.signature {
|
||||||
data.append(signature.prefix(signatureSize))
|
data.append(signature.prefix(signatureSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Apply padding to standard block sizes for traffic analysis resistance
|
|
||||||
if padding {
|
if padding {
|
||||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
return MessagePadding.pad(data, toSize: optimalSize)
|
||||||
return paddedData
|
|
||||||
} else {
|
|
||||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
@@ -227,87 +271,129 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
// Minimum size: header + senderID
|
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
||||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
|
||||||
|
|
||||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
guard let base = buf.baseAddress else { return nil }
|
guard let base = buf.baseAddress else { return nil }
|
||||||
var offset = 0
|
var offset = 0
|
||||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
// Read single byte
|
|
||||||
func read8() -> UInt8? {
|
func read8() -> UInt8? {
|
||||||
guard require(1) else { return nil }
|
guard require(1) else { return nil }
|
||||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
offset += 1
|
offset += 1
|
||||||
return v
|
return value
|
||||||
}
|
}
|
||||||
// Read big-endian 16-bit
|
|
||||||
func read16() -> UInt16? {
|
func read16() -> UInt16? {
|
||||||
guard require(2) else { return nil }
|
guard require(2) else { return nil }
|
||||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return v
|
return value
|
||||||
|
}
|
||||||
|
func read32() -> UInt32? {
|
||||||
|
guard require(4) else { return nil }
|
||||||
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
|
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
||||||
|
offset += 4
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
// Copy N bytes into Data
|
|
||||||
func readData(_ n: Int) -> Data? {
|
func readData(_ n: Int) -> Data? {
|
||||||
guard require(n) else { return nil }
|
guard require(n) else { return nil }
|
||||||
let ptr = base.advanced(by: offset)
|
let ptr = base.advanced(by: offset)
|
||||||
let d = Data(bytes: ptr, count: n)
|
let data = Data(bytes: ptr, count: n)
|
||||||
offset += n
|
offset += n
|
||||||
return d
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version
|
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
||||||
guard let version = read8(), version == 1 else { return nil }
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
guard let type = read8() else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
guard let ttl = read8() else { return nil }
|
let minimumRequired = headerSize + senderIDSize
|
||||||
|
guard raw.count >= minimumRequired else { return nil }
|
||||||
|
|
||||||
// Timestamp 8 bytes BE
|
guard let type = read8(), let ttl = read8() else { return nil }
|
||||||
guard require(8) else { return nil }
|
|
||||||
var ts: UInt64 = 0
|
var timestamp: UInt64 = 0
|
||||||
for _ in 0..<8 {
|
for _ in 0..<8 {
|
||||||
guard let b = read8() else { return nil }
|
guard let byte = read8() else { return nil }
|
||||||
ts = (ts << 8) | UInt64(b)
|
timestamp = (timestamp << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
guard let flags = read8() else { return nil }
|
guard let flags = read8() else { return nil }
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
// Payload length
|
let payloadLength: Int
|
||||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
if version == 2 {
|
||||||
|
guard let len = read32() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
} else {
|
||||||
|
guard let len = read16() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard payloadLength >= 0 else { return nil }
|
||||||
|
|
||||||
// SenderID
|
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
// Recipient
|
|
||||||
var recipientID: Data? = nil
|
var recipientID: Data? = nil
|
||||||
if hasRecipient {
|
if hasRecipient {
|
||||||
recipientID = readData(recipientIDSize)
|
recipientID = readData(recipientIDSize)
|
||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload
|
var route: [Data]? = nil
|
||||||
|
var remainingPayloadBytes = payloadLength
|
||||||
|
|
||||||
|
if (flags & Flags.hasRoute) != 0 {
|
||||||
|
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
|
||||||
|
remainingPayloadBytes -= 1
|
||||||
|
if routeCount > 0 {
|
||||||
|
var hops: [Data] = []
|
||||||
|
for _ in 0..<Int(routeCount) {
|
||||||
|
guard remainingPayloadBytes >= senderIDSize,
|
||||||
|
let hop = readData(senderIDSize) else { return nil }
|
||||||
|
remainingPayloadBytes -= senderIDSize
|
||||||
|
hops.append(hop)
|
||||||
|
}
|
||||||
|
route = hops
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
// Need original size (2 bytes)
|
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
|
||||||
guard let origSize16 = read16() else { return nil }
|
let originalSize: Int
|
||||||
let originalSize = Int(origSize16)
|
if version == 2 {
|
||||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
let compSize = Int(payloadLen) - 2
|
originalSize = Int(rawSize)
|
||||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
} else {
|
||||||
|
guard let rawSize = read16() else { return nil }
|
||||||
|
originalSize = Int(rawSize)
|
||||||
|
}
|
||||||
|
remainingPayloadBytes -= lengthFieldBytes
|
||||||
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
|
let compressedSize = remainingPayloadBytes
|
||||||
|
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
|
|
||||||
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
|
guard compressionRatio <= 50_000.0 else {
|
||||||
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
guard remainingPayloadBytes >= 0,
|
||||||
payload = p
|
let rawPayload = readData(remainingPayloadBytes) else { return nil }
|
||||||
|
remainingPayloadBytes = 0
|
||||||
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature
|
|
||||||
var signature: Data? = nil
|
var signature: Data? = nil
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
signature = readData(signatureSize)
|
signature = readData(signatureSize)
|
||||||
@@ -320,10 +406,12 @@ struct BinaryProtocol {
|
|||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl
|
ttl: ttl,
|
||||||
|
version: version,
|
||||||
|
route: route
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// BitchatFilePacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||||
|
struct BitchatFilePacket {
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var mimeType: String?
|
||||||
|
var content: Data
|
||||||
|
|
||||||
|
/// Canonical TLV tags defined by the Android implementation.
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case fileName = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case mimeType = 0x03
|
||||||
|
case content = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
|
func encode() -> Data? {
|
||||||
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
|
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||||
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
|
||||||
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
|
var big = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
|
||||||
|
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||||
|
encoded.append(TLVType.fileName.rawValue)
|
||||||
|
appendBE(UInt16(nameData.count), into: &encoded)
|
||||||
|
encoded.append(nameData)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.append(TLVType.fileSize.rawValue)
|
||||||
|
appendBE(UInt16(4), into: &encoded)
|
||||||
|
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||||
|
|
||||||
|
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||||
|
encoded.append(TLVType.mimeType.rawValue)
|
||||||
|
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||||
|
encoded.append(mimeData)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.append(TLVType.content.rawValue)
|
||||||
|
appendBE(UInt32(content.count), into: &encoded)
|
||||||
|
encoded.append(content)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
|
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var mimeType: String?
|
||||||
|
var content = Data()
|
||||||
|
|
||||||
|
while cursor < end {
|
||||||
|
let typeRaw = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
|
||||||
|
guard cursor <= end else { return nil }
|
||||||
|
let tlvType = TLVType(rawValue: typeRaw)
|
||||||
|
|
||||||
|
func readBigEndianLength(bytes: Int) -> Int? {
|
||||||
|
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||||
|
// Use UInt64 to prevent integer overflow during shift operations
|
||||||
|
var result: UInt64 = 0
|
||||||
|
for _ in 0..<bytes {
|
||||||
|
result = (result << 8) | UInt64(data[cursor])
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
}
|
||||||
|
// Safely convert to Int with overflow check
|
||||||
|
guard result <= Int.max else { return nil }
|
||||||
|
return Int(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let length: Int?
|
||||||
|
if tlvType == .content {
|
||||||
|
let snapshot = cursor
|
||||||
|
let canonical = readBigEndianLength(bytes: 4)
|
||||||
|
if let canonical = canonical,
|
||||||
|
canonical <= data.distance(from: cursor, to: end) {
|
||||||
|
length = canonical
|
||||||
|
} else {
|
||||||
|
cursor = snapshot
|
||||||
|
length = readBigEndianLength(bytes: 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
length = readBigEndianLength(bytes: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||||
|
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||||
|
|
||||||
|
let valueStart = cursor
|
||||||
|
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||||
|
let value = data[valueStart..<cursor]
|
||||||
|
|
||||||
|
switch tlvType {
|
||||||
|
case .fileName:
|
||||||
|
fileName = String(data: Data(value), encoding: .utf8)
|
||||||
|
case .fileSize:
|
||||||
|
if tlvLength == 4 || tlvLength == 8 {
|
||||||
|
var size: UInt64 = 0
|
||||||
|
for byte in value {
|
||||||
|
size = (size << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fileSize = size
|
||||||
|
}
|
||||||
|
case .mimeType:
|
||||||
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
let proposedSize = content.count + value.count
|
||||||
|
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content.append(contentsOf: value)
|
||||||
|
case nil:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !content.isEmpty else { return nil }
|
||||||
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
return BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
mimeType: mimeType,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ enum MessageType: UInt8 {
|
|||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -88,6 +90,7 @@ enum MessageType: UInt8 {
|
|||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +134,7 @@ enum LazyHandshakeState {
|
|||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
// Delivery status for messages
|
// Delivery status for messages
|
||||||
enum DeliveryStatus: Codable, Equatable {
|
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
case sending
|
case sending
|
||||||
case sent // Left our device
|
case sent // Left our device
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
@@ -161,18 +164,21 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: String)
|
func didConnectToPeer(_ peerID: PeerID)
|
||||||
func didDisconnectFromPeer(_ peerID: String)
|
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||||
func didUpdatePeerList(_ peers: [String])
|
func didUpdatePeerList(_ peers: [PeerID])
|
||||||
|
|
||||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||||
|
|
||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
|
||||||
|
// Bluetooth state updates for user notifications
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -185,11 +191,11 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Validates a geohash string for building-level precision (8 characters).
|
||||||
|
/// - Parameter geohash: The geohash string to validate
|
||||||
|
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||||
|
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||||
|
guard geohash.count == 8 else { return false }
|
||||||
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
|
}
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - latitude: Latitude in degrees (-90...90)
|
/// - latitude: Latitude in degrees (-90...90)
|
||||||
@@ -111,4 +119,57 @@ enum Geohash {
|
|||||||
}
|
}
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||||
|
static func neighbors(of geohash: String) -> [String] {
|
||||||
|
guard !geohash.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let precision = geohash.count
|
||||||
|
let bounds = decodeBounds(geohash)
|
||||||
|
let center = decodeCenter(geohash)
|
||||||
|
|
||||||
|
// Calculate cell dimensions
|
||||||
|
let latHeight = bounds.latMax - bounds.latMin
|
||||||
|
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||||
|
|
||||||
|
// Helper to wrap longitude around ±180
|
||||||
|
func wrapLongitude(_ lon: Double) -> Double {
|
||||||
|
var wrapped = lon
|
||||||
|
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||||
|
while wrapped < -180.0 { wrapped += 360.0 }
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to clamp latitude to ±90
|
||||||
|
func clampLatitude(_ lat: Double) -> Double {
|
||||||
|
return max(-90.0, min(90.0, lat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate 8 neighbor centers
|
||||||
|
let neighbors: [(lat: Double, lon: Double)] = [
|
||||||
|
(center.lat + latHeight, center.lon), // N
|
||||||
|
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||||
|
(center.lat, center.lon + lonWidth), // E
|
||||||
|
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||||
|
(center.lat - latHeight, center.lon), // S
|
||||||
|
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||||
|
(center.lat, center.lon - lonWidth), // W
|
||||||
|
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||||
|
]
|
||||||
|
|
||||||
|
// Encode each neighbor, handling boundary conditions
|
||||||
|
return neighbors.compactMap { neighbor in
|
||||||
|
let lat = clampLatitude(neighbor.lat)
|
||||||
|
let lon = wrapLongitude(neighbor.lon)
|
||||||
|
|
||||||
|
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||||
|
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,21 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building: return "Building"
|
case .building:
|
||||||
case .block: return "Block"
|
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||||
case .neighborhood: return "Neighborhood"
|
case .block:
|
||||||
case .city: return "City"
|
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||||
case .province: return "Province"
|
case .neighborhood:
|
||||||
case .region: return "Region"
|
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||||
|
case .city:
|
||||||
|
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||||
|
case .province:
|
||||||
|
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||||
|
case .region:
|
||||||
|
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Backward-compatible Codable for renamed cases
|
// Backward-compatible Codable for renamed cases
|
||||||
extension GeohashChannelLevel {
|
extension GeohashChannelLevel {
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -110,4 +116,18 @@ enum ChannelID: Equatable, Codable {
|
|||||||
case .location(let ch): return ch.geohash
|
case .location(let ch): return ch.geohash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isMesh: Bool {
|
||||||
|
switch self {
|
||||||
|
case .mesh: true
|
||||||
|
case .location: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLocation: Bool {
|
||||||
|
switch self {
|
||||||
|
case .mesh: false
|
||||||
|
case .location: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
|
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
|
case directNeighbors = 0x04
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
|
|||||||
data.append(TLVType.signingPublicKey.rawValue)
|
data.append(TLVType.signingPublicKey.rawValue)
|
||||||
data.append(UInt8(signingPublicKey.count))
|
data.append(UInt8(signingPublicKey.count))
|
||||||
data.append(signingPublicKey)
|
data.append(signingPublicKey)
|
||||||
|
|
||||||
|
// TLV for direct neighbors (optional)
|
||||||
|
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
||||||
|
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
||||||
|
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
||||||
|
data.append(TLVType.directNeighbors.rawValue)
|
||||||
|
data.append(UInt8(neighborsData.count))
|
||||||
|
data.append(neighborsData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
|
|||||||
var nickname: String?
|
var nickname: String?
|
||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
|
var directNeighbors: [Data]?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey = Data(value)
|
noisePublicKey = Data(value)
|
||||||
case .signingPublicKey:
|
case .signingPublicKey:
|
||||||
signingPublicKey = Data(value)
|
signingPublicKey = Data(value)
|
||||||
|
case .directNeighbors:
|
||||||
|
if length > 0 && length % 8 == 0 {
|
||||||
|
var neighbors = [Data]()
|
||||||
|
let count = length / 8
|
||||||
|
for i in 0..<count {
|
||||||
|
let start = value.startIndex + i * 8
|
||||||
|
let end = start + 8
|
||||||
|
neighbors.append(Data(value[start..<end]))
|
||||||
|
}
|
||||||
|
directNeighbors = neighbors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
|
|||||||
return AnnouncementPacket(
|
return AnnouncementPacket(
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey
|
signingPublicKey: signingPublicKey,
|
||||||
|
directNeighbors: directNeighbors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Peer ID Utilities
|
|
||||||
|
|
||||||
struct PeerIDUtils {
|
|
||||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
|
||||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: publicKey)
|
|
||||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
return String(hex.prefix(16))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// MimeType.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
// MARK: - Extensions for missing UTTypes
|
||||||
|
|
||||||
|
extension UTType {
|
||||||
|
static let webP = UTType(importedAs: "image/webp")
|
||||||
|
static let aac = UTType(importedAs: "audio/aac")
|
||||||
|
static let m4a = UTType(importedAs: "audio/m4a")
|
||||||
|
static let ogg = UTType(importedAs: "audio/ogg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MimeType Enum
|
||||||
|
|
||||||
|
enum MimeType: CaseIterable, Hashable {
|
||||||
|
case jpeg
|
||||||
|
case jpg
|
||||||
|
case png
|
||||||
|
case gif
|
||||||
|
case webp
|
||||||
|
case mp4Audio
|
||||||
|
case m4a
|
||||||
|
case aac
|
||||||
|
case mpeg
|
||||||
|
case mp3
|
||||||
|
case wav
|
||||||
|
case xWav
|
||||||
|
case ogg
|
||||||
|
case pdf
|
||||||
|
case octetStream
|
||||||
|
|
||||||
|
var utType: UTType {
|
||||||
|
switch self {
|
||||||
|
case .jpeg, .jpg: .jpeg
|
||||||
|
case .png: .png
|
||||||
|
case .gif: .gif
|
||||||
|
case .webp: .webP
|
||||||
|
case .aac: .aac
|
||||||
|
case .m4a: .m4a
|
||||||
|
case .mp4Audio: .mpeg4Audio
|
||||||
|
case .mp3, .mpeg: .mp3
|
||||||
|
case .wav, .xWav: .wav
|
||||||
|
case .ogg: .ogg
|
||||||
|
case .pdf: .pdf
|
||||||
|
case .octetStream: .data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var category: Category {
|
||||||
|
switch self {
|
||||||
|
case .jpeg, .jpg, .png, .gif, .webp:
|
||||||
|
return .image
|
||||||
|
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
||||||
|
return .audio
|
||||||
|
case .pdf, .octetStream:
|
||||||
|
return .file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var mimeString: String {
|
||||||
|
switch self {
|
||||||
|
case .jpeg, .jpg: "image/jpeg"
|
||||||
|
case .png: "image/png"
|
||||||
|
case .gif: "image/gif"
|
||||||
|
case .webp: "image/webp"
|
||||||
|
case .mp4Audio: "audio/mp4"
|
||||||
|
case .m4a: "audio/m4a"
|
||||||
|
case .aac: "audio/aac"
|
||||||
|
case .mpeg: "audio/mpeg"
|
||||||
|
case .mp3: "audio/mp3"
|
||||||
|
case .wav: "audio/wav"
|
||||||
|
case .xWav: "audio/x-wav"
|
||||||
|
case .ogg: "audio/ogg"
|
||||||
|
case .pdf: "application/pdf"
|
||||||
|
case .octetStream: "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultExtension: String {
|
||||||
|
switch self {
|
||||||
|
case .jpeg, .jpg: "jpg"
|
||||||
|
case .png: "png"
|
||||||
|
case .webp: "webp"
|
||||||
|
case .gif: "gif"
|
||||||
|
case .mp4Audio, .m4a, .aac: "m4a"
|
||||||
|
case .mpeg, .mp3: "mp3"
|
||||||
|
case .wav, .xWav: "wav"
|
||||||
|
case .ogg: "ogg"
|
||||||
|
case .pdf: "pdf"
|
||||||
|
case .octetStream: "bin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var allowed: Set<MimeType> = [
|
||||||
|
.jpeg, .jpg, .png, .gif, .webp,
|
||||||
|
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
||||||
|
.wav, .xWav, .ogg,
|
||||||
|
.pdf, .octetStream
|
||||||
|
]
|
||||||
|
|
||||||
|
var isAllowed: Bool {
|
||||||
|
Self.allowed.contains(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Byte signature validation
|
||||||
|
func matches(data: Data) -> Bool {
|
||||||
|
guard !data.isEmpty else { return false }
|
||||||
|
|
||||||
|
// Generic type → skip validation
|
||||||
|
if self == .octetStream { return true }
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .jpeg, .jpg:
|
||||||
|
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
||||||
|
|
||||||
|
case .png:
|
||||||
|
return data.count >= 8 &&
|
||||||
|
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||||
|
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
||||||
|
|
||||||
|
case .gif:
|
||||||
|
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
||||||
|
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
||||||
|
|
||||||
|
case .webp:
|
||||||
|
return data.count >= 12 &&
|
||||||
|
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||||
|
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
||||||
|
|
||||||
|
case .m4a, .mp4Audio, .aac:
|
||||||
|
// AVAudioRecorder output varies by platform - be lenient
|
||||||
|
// Security: size already capped + sandboxed execution
|
||||||
|
return data.count > 100
|
||||||
|
|
||||||
|
case .mpeg, .mp3:
|
||||||
|
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
||||||
|
return true // ID3 header
|
||||||
|
}
|
||||||
|
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
||||||
|
|
||||||
|
case .wav, .xWav:
|
||||||
|
return data.count >= 12 &&
|
||||||
|
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||||
|
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
||||||
|
|
||||||
|
case .ogg:
|
||||||
|
return data.count >= 4 &&
|
||||||
|
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
||||||
|
|
||||||
|
case .pdf:
|
||||||
|
return data.count >= 4 &&
|
||||||
|
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Initializers
|
||||||
|
|
||||||
|
init?(_ mimeString: String?) {
|
||||||
|
guard let mimeString else { return nil }
|
||||||
|
|
||||||
|
let normalized = mimeString.lowercased()
|
||||||
|
|
||||||
|
// Direct match with our canonical list
|
||||||
|
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
||||||
|
self = match
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
||||||
|
if let type = UTType(mimeType: normalized),
|
||||||
|
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
||||||
|
self = match
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MimeType {
|
||||||
|
enum Category: String {
|
||||||
|
case audio, image, file
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -65,14 +65,11 @@ final class CommandProcessor {
|
|||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
//
|
|
||||||
case "/help", "/h":
|
|
||||||
return .error(message: "unknown command: \(cmd)")
|
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
@@ -104,7 +101,7 @@ final class CommandProcessor {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.visibleGeohashPeople().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
@@ -150,8 +147,8 @@ final class CommandProcessor {
|
|||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
messageID: UUID().uuidString)
|
||||||
// Also add a local system message so the sender sees a natural-language confirmation
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
let pastAction: String = {
|
let pastAction: String = {
|
||||||
@@ -285,7 +282,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let noisePublicKey = Data(hexString: peerID) else {
|
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,19 +308,4 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHelp() -> CommandResult {
|
|
||||||
let helpText = """
|
|
||||||
commands:
|
|
||||||
/msg @name - start private chat
|
|
||||||
/who - list who's online
|
|
||||||
/clear - clear messages
|
|
||||||
/hug @name - send a hug
|
|
||||||
/slap @name - slap with a trout
|
|
||||||
/fav @name - add to favorites
|
|
||||||
/unfav @name - remove from favorites
|
|
||||||
/block @name - block
|
|
||||||
/unblock @name - unblock
|
|
||||||
"""
|
|
||||||
return .success(message: helpText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
private init() {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -179,125 +181,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
guard peerID.count == 16 else { return nil }
|
guard peerID.isShort else { return nil }
|
||||||
for (pubkey, rel) in favorites {
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
return rel
|
||||||
if derived == peerID { return rel }
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update Nostr public key for a peer
|
|
||||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: nostrPubkey,
|
|
||||||
peerNickname: existing.peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update nickname for an existing favorite
|
|
||||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
// Skip if nickname hasn't changed
|
|
||||||
if existing.peerNickname == newNickname { return }
|
|
||||||
|
|
||||||
// Updating nickname for favorite
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: newNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update noise public key when peer reconnects with new ID
|
|
||||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
|
||||||
guard let existing = favorites[oldKey] else {
|
|
||||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already have a favorite with the new key
|
|
||||||
if favorites[newKey] != nil {
|
|
||||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
saveFavorites()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updating noise public key
|
|
||||||
|
|
||||||
// Remove old entry
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
|
|
||||||
// Add with new key
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: newKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[newKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers with both old and new keys
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"peerPublicKey": newKey,
|
|
||||||
"oldPeerPublicKey": oldKey,
|
|
||||||
"isKeyUpdate": true
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorites (including non-mutual)
|
|
||||||
func getAllFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isFavorite }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get only mutual favorites
|
|
||||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isMutual }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorite relationships (including where they favorited us)
|
|
||||||
func getAllRelationships() -> [FavoriteRelationship] {
|
|
||||||
Array(favorites.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all favorites - used for panic mode
|
/// Clear all favorites - used for panic mode
|
||||||
func clearAllFavorites() {
|
func clearAllFavorites() {
|
||||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||||
@@ -306,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
KeychainHelper.delete(
|
keychain.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -326,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
KeychainHelper.save(
|
keychain.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService
|
service: Self.keychainService,
|
||||||
|
accessible: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
@@ -341,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = KeychainHelper.load(
|
guard let data = keychain.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let storage: UserDefaults
|
||||||
|
|
||||||
private init() {
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
private func load() {
|
private func load() {
|
||||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
guard let data = storage.data(forKey: storeKey) else { return }
|
||||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
@@ -81,7 +84,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
membership = seen
|
membership = seen
|
||||||
}
|
}
|
||||||
// Load any saved names
|
// Load any saved names
|
||||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
if let namesData = storage.data(forKey: namesStoreKey),
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
bookmarkNames = dict
|
bookmarkNames = dict
|
||||||
}
|
}
|
||||||
@@ -89,13 +92,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
UserDefaults.standard.set(data, forKey: storeKey)
|
storage.set(data, forKey: storeKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistNames() {
|
private func persistNames() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
storage.set(data, forKey: namesStoreKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Testing-only reset helper
|
|
||||||
func _resetForTesting() {
|
|
||||||
bookmarks.removeAll()
|
|
||||||
membership.removeAll()
|
|
||||||
bookmarkNames.removeAll()
|
|
||||||
persist()
|
|
||||||
persistNames()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
private func isSandboxed() -> Bool {
|
|
||||||
#if os(macOS)
|
|
||||||
// More robust sandbox detection using multiple methods
|
|
||||||
|
|
||||||
// Method 1: Check environment variable (can be spoofed)
|
|
||||||
let environment = ProcessInfo.processInfo.environment
|
|
||||||
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
|
||||||
|
|
||||||
// Method 2: Check if we can access a path outside sandbox
|
|
||||||
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
|
|
||||||
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
|
|
||||||
if canWriteOutsideSandbox {
|
|
||||||
try? FileManager.default.removeItem(at: testPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Check container path
|
|
||||||
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
|
|
||||||
let hasContainerPath = containerPath.contains("/Containers/")
|
|
||||||
|
|
||||||
// If any method indicates sandbox, we consider it sandboxed
|
|
||||||
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
|
|
||||||
#else
|
|
||||||
// iOS is always sandboxed
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
switch status {
|
switch status {
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
break // will compute from location
|
break // will compute from location
|
||||||
default:
|
case .notDetermined, .restricted, .denied:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
if case .location(let ch) = selectedChannel {
|
if case .location(let ch) = selectedChannel {
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct LocationNotesCounterDependencies {
|
|
||||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
|
||||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
|
||||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
|
||||||
|
|
||||||
var relayLookup: RelayLookup
|
|
||||||
var subscribe: Subscribe
|
|
||||||
var unsubscribe: Unsubscribe
|
|
||||||
|
|
||||||
static let live = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { geohash, count in
|
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
|
||||||
},
|
|
||||||
subscribe: { filter, id, relays, handler, onEOSE in
|
|
||||||
NostrRelayManager.shared.subscribe(
|
|
||||||
filter: filter,
|
|
||||||
id: id,
|
|
||||||
relayUrls: relays,
|
|
||||||
handler: handler,
|
|
||||||
onEOSE: onEOSE
|
|
||||||
)
|
|
||||||
},
|
|
||||||
unsubscribe: { id in
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
|
||||||
@MainActor
|
|
||||||
final class LocationNotesCounter: ObservableObject {
|
|
||||||
static let shared = LocationNotesCounter()
|
|
||||||
|
|
||||||
@Published private(set) var geohash: String? = nil
|
|
||||||
@Published private(set) var count: Int? = 0
|
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
|
||||||
@Published private(set) var relayAvailable: Bool = true
|
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
|
||||||
private var noteIDs = Set<String>()
|
|
||||||
private let dependencies: LocationNotesCounterDependencies
|
|
||||||
|
|
||||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
init(testDependencies: LocationNotesCounterDependencies) {
|
|
||||||
self.dependencies = testDependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
|
||||||
let norm = gh.lowercased()
|
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = norm
|
|
||||||
noteIDs.removeAll()
|
|
||||||
initialLoadComplete = false
|
|
||||||
relayAvailable = true
|
|
||||||
|
|
||||||
// Subscribe only to the building geohash (precision 8)
|
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
|
||||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else {
|
|
||||||
relayAvailable = false
|
|
||||||
initialLoadComplete = true
|
|
||||||
count = 0
|
|
||||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptionID = subID
|
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
|
||||||
if !self.noteIDs.contains(event.id) {
|
|
||||||
self.noteIDs.insert(event.id)
|
|
||||||
self.count = self.noteIDs.count
|
|
||||||
}
|
|
||||||
}, { [weak self] in
|
|
||||||
self?.initialLoadComplete = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = nil
|
|
||||||
count = 0
|
|
||||||
noteIDs.removeAll()
|
|
||||||
relayAvailable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
|
|||||||
var sendEvent: SendEvent
|
var sendEvent: SendEvent
|
||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
},
|
},
|
||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||||
},
|
},
|
||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
@@ -74,23 +76,52 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var state: State = .loading
|
@Published private(set) var state: State = .loading
|
||||||
@Published private(set) var errorMessage: String?
|
@Published private(set) var errorMessage: String?
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
private let dependencies: LocationNotesDependencies
|
private let dependencies: LocationNotesDependencies
|
||||||
|
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||||
|
|
||||||
|
static func failedToSend(_ detail: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||||
|
locale: .current,
|
||||||
|
detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
self.geohash = geohash.lowercased()
|
let norm = geohash.lowercased()
|
||||||
|
self.geohash = norm
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
if !Geohash.isValidBuildingGeohash(norm) {
|
||||||
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
}
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +130,12 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
dependencies.unsubscribe(sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,27 +156,37 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
initialLoadComplete = true
|
initialLoadComplete = true
|
||||||
state = .noRelays
|
state = .noRelays
|
||||||
errorMessage = "No geo relays available near this location. Try again soon."
|
errorMessage = Strings.noRelays
|
||||||
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||||
|
let neighbors = Geohash.neighbors(of: geohash)
|
||||||
|
let allGeohashes = [geohash] + neighbors
|
||||||
|
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||||
|
|
||||||
|
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||||
|
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||||
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag - accept any of our 9 geohashes
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
guard event.tags.contains(where: { tag in
|
||||||
if self.notes.contains(where: { $0.id == event.id }) { return }
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||||
|
}) else { return }
|
||||||
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
|
self.enforceMemoryCap()
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
}, { [weak self] in
|
}, { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -158,7 +204,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
guard !relays.isEmpty else {
|
guard !relays.isEmpty else {
|
||||||
state = .noRelays
|
state = .noRelays
|
||||||
errorMessage = "No geo relays available near this location. Try again soon."
|
errorMessage = Strings.noRelays
|
||||||
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,15 +222,26 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: id.publicKeyHex,
|
pubkey: id.publicKeyHex,
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: dependencies.now(),
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
nickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.enforceMemoryCap()
|
||||||
self.state = .ready
|
self.state = .ready
|
||||||
self.errorMessage = nil
|
self.errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
errorMessage = Strings.failedToSend(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||||
|
private func enforceMemoryCap() {
|
||||||
|
if notes.count > maxNotesInMemory {
|
||||||
|
let removed = notes.count - maxNotesInMemory
|
||||||
|
notes = Array(notes.prefix(maxNotesInMemory))
|
||||||
|
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tracks observed mesh topology and computes hop-by-hop routes.
|
||||||
|
final class MeshTopologyTracker {
|
||||||
|
private typealias RoutingID = Data
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
||||||
|
private let hopSize = 8
|
||||||
|
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
self.adjacency.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDirectLink(between a: Data?, and b: Data?) {
|
||||||
|
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
var setA = self.adjacency[left] ?? []
|
||||||
|
setA.insert(right)
|
||||||
|
self.adjacency[left] = setA
|
||||||
|
|
||||||
|
var setB = self.adjacency[right] ?? []
|
||||||
|
setB.insert(left)
|
||||||
|
self.adjacency[right] = setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDirectLink(between a: Data?, and b: Data?) {
|
||||||
|
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
if var setA = self.adjacency[left] {
|
||||||
|
setA.remove(right)
|
||||||
|
self.adjacency[left] = setA.isEmpty ? nil : setA
|
||||||
|
}
|
||||||
|
if var setB = self.adjacency[right] {
|
||||||
|
setB.remove(left)
|
||||||
|
self.adjacency[right] = setB.isEmpty ? nil : setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePeer(_ data: Data?) {
|
||||||
|
guard let peer = sanitize(data) else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
|
||||||
|
for neighbor in neighbors {
|
||||||
|
if var set = self.adjacency[neighbor] {
|
||||||
|
set.remove(peer)
|
||||||
|
self.adjacency[neighbor] = set.isEmpty ? nil : set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordRoute(_ hops: [Data]) {
|
||||||
|
let sanitized = hops.compactMap { sanitize($0) }
|
||||||
|
guard sanitized.count >= 2 else { return }
|
||||||
|
queue.sync(flags: .barrier) {
|
||||||
|
for idx in 0..<(sanitized.count - 1) {
|
||||||
|
let left = sanitized[idx]
|
||||||
|
let right = sanitized[idx + 1]
|
||||||
|
guard left != right else { continue }
|
||||||
|
|
||||||
|
var setA = self.adjacency[left] ?? []
|
||||||
|
setA.insert(right)
|
||||||
|
self.adjacency[left] = setA
|
||||||
|
|
||||||
|
var setB = self.adjacency[right] ?? []
|
||||||
|
setB.insert(left)
|
||||||
|
self.adjacency[right] = setB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
|
||||||
|
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||||
|
if source == target { return [source] }
|
||||||
|
|
||||||
|
let graph = queue.sync { adjacency }
|
||||||
|
guard graph[source] != nil, graph[target] != nil else { return nil }
|
||||||
|
|
||||||
|
var visited: Set<RoutingID> = [source]
|
||||||
|
var queuePaths: [[RoutingID]] = [[source]]
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
while index < queuePaths.count {
|
||||||
|
let path = queuePaths[index]
|
||||||
|
index += 1
|
||||||
|
guard path.count <= maxHops else { continue }
|
||||||
|
guard let last = path.last, let neighbors = graph[last] else { continue }
|
||||||
|
|
||||||
|
for neighbor in neighbors {
|
||||||
|
if visited.contains(neighbor) { continue }
|
||||||
|
var nextPath = path
|
||||||
|
nextPath.append(neighbor)
|
||||||
|
if neighbor == target { return nextPath }
|
||||||
|
if nextPath.count <= maxHops {
|
||||||
|
queuePaths.append(nextPath)
|
||||||
|
}
|
||||||
|
visited.insert(neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func sanitize(_ data: Data?) -> Data? {
|
||||||
|
guard var value = data, !value.isEmpty else { return nil }
|
||||||
|
if value.count > hopSize {
|
||||||
|
value = Data(value.prefix(hopSize))
|
||||||
|
} else if value.count < hopSize {
|
||||||
|
value.append(Data(repeating: 0, count: hopSize - value.count))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import Foundation
|
|||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrTransport
|
private let nostr: NostrTransport
|
||||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
init(mesh: Transport, nostr: NostrTransport) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.mesh = mesh
|
self.mesh = mesh
|
||||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
|||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
let peerID = PeerID(publicKey: data)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
|||||||
// Handle key updates
|
// Handle key updates
|
||||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
let peerID = PeerID(publicKey: newKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -37,44 +37,44 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
if reachableMesh {
|
if reachableMesh {
|
||||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// BLEService will initiate a handshake if needed and queue the message
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
nostr.sendReadReceipt(receipt, to: peerID)
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
// Route via mesh when connected; else use Nostr
|
// Route via mesh when connected; else use Nostr
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
// Two forms are supported:
|
// Two forms are supported:
|
||||||
// - 64-hex Noise public key (32 bytes)
|
// - 64-hex Noise public key (32 bytes)
|
||||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
if let noiseKey = peerID.noiseKey {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if peerID.count == 16 {
|
} else if peerID.isShort {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: String) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Keep unsent items queued
|
// Keep unsent items queued
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
/// Policy: permit start when either location permissions are authorized OR
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
|
|||||||
@@ -115,15 +115,30 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return "Encryption failed"
|
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return "Not encrypted"
|
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return "Establishing encryption..."
|
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return "Encrypted"
|
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return "Encrypted & Verified"
|
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessibilityDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||||
|
case .noHandshake:
|
||||||
|
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||||
|
case .noiseHandshaking:
|
||||||
|
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||||
|
case .noiseSecured:
|
||||||
|
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||||
|
case .noiseVerified:
|
||||||
|
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,8 +162,8 @@ final class NoiseEncryptionService {
|
|||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
@@ -162,18 +177,18 @@ final class NoiseEncryptionService {
|
|||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy support - setting this will add to the handlers array
|
// Legacy support - setting this will add to the handlers array
|
||||||
var onPeerAuthenticated: ((String, String) -> Void)? {
|
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
||||||
get { nil } // Always return nil for backward compatibility
|
get { nil } // Always return nil for backward compatibility
|
||||||
set {
|
set {
|
||||||
if let handler = newValue {
|
if let handler = newValue {
|
||||||
@@ -257,12 +272,11 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Get our identity fingerprint
|
/// Get our identity fingerprint
|
||||||
func getIdentityFingerprint() -> String {
|
func getIdentityFingerprint() -> String {
|
||||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,11 +404,11 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +418,7 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||||
|
|
||||||
// Return raw handshake data without wrapper
|
// Return raw handshake data without wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
@@ -413,17 +427,17 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,19 +457,19 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have a session (established or handshaking) with a peer
|
/// Check if we have a session (established or handshaking) with a peer
|
||||||
func hasSession(with peerID: String) -> Bool {
|
func hasSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
return sessionManager.getSession(for: peerID) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
/// Encrypt data for a specific peer
|
/// Encrypt data for a specific peer
|
||||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -477,7 +491,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -499,32 +513,11 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
return peerFingerprints[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for a fingerprint
|
|
||||||
func getPeerID(for fingerprint: String) -> String? {
|
|
||||||
return serviceQueue.sync {
|
|
||||||
return fingerprintToPeerID[fingerprint]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a peer session
|
|
||||||
func removePeer(_ peerID: String) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
if let fingerprint = peerFingerprints[peerID] {
|
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
||||||
}
|
|
||||||
peerFingerprints.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
@@ -537,9 +530,9 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -548,7 +541,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
@@ -557,11 +550,6 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
|
||||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -4,46 +4,51 @@ import Combine
|
|||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport {
|
final class NostrTransport: Transport {
|
||||||
weak var delegate: BitchatDelegate?
|
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
|
||||||
Just([]).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
|
||||||
|
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID: String = ""
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
// Throttle READ receipts to avoid relay rate limits
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
private struct QueuedRead {
|
private struct QueuedRead {
|
||||||
let receipt: ReadReceipt
|
let receipt: ReadReceipt
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
}
|
}
|
||||||
private var readQueue: [QueuedRead] = []
|
private var readQueue: [QueuedRead] = []
|
||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
|
Just([]).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||||
|
|
||||||
|
var myPeerID: PeerID { senderPeerID }
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
func startServices() { /* no-op */ }
|
func startServices() { /* no-op */ }
|
||||||
func stopServices() { /* no-op */ }
|
func stopServices() { /* no-op */ }
|
||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||||
|
|
||||||
func getFingerprint(for peerID: String) -> String? { nil }
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService?
|
||||||
@@ -59,11 +64,11 @@ final class NostrTransport: Transport {
|
|||||||
// Public broadcast not supported over Nostr here
|
// Public broadcast not supported over Nostr here
|
||||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Convert recipient npub -> hex (x-only)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -90,12 +95,113 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
processReadQueueIfNeeded()
|
processReadQueueIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash Helpers
|
||||||
|
|
||||||
|
extension NostrTransport {
|
||||||
|
|
||||||
|
// MARK: Geohash ACK helpers
|
||||||
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Geohash DMs (per-geohash identity)
|
||||||
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !recipientHex.isEmpty else { return }
|
||||||
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
extension NostrTransport {
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
guard !readQueue.isEmpty else { return }
|
guard !readQueue.isEmpty else { return }
|
||||||
@@ -108,7 +214,7 @@ final class NostrTransport: Transport {
|
|||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
@@ -139,111 +245,18 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
// Convert recipient npub -> hex
|
|
||||||
let recipientHex: String
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
|
||||||
guard hrp == "npub" else { return }
|
|
||||||
recipientHex = data.hexEncodedString()
|
|
||||||
} catch { return }
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.count == 16,
|
if peerID.id.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
let recipientHex: String
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
|
||||||
guard hrp == "npub" else { return }
|
|
||||||
recipientHex = data.hexEncodedString()
|
|
||||||
} catch { return }
|
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash ACK helpers
|
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash DMs (per-geohash identity)
|
|
||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard !recipientHex.isEmpty else { return }
|
|
||||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,28 +29,30 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
func sendLocalNotification(
|
||||||
// For now, skip app state check entirely to avoid thread issues
|
title: String,
|
||||||
// The NotificationDelegate will handle foreground presentation
|
body: String,
|
||||||
DispatchQueue.main.async {
|
identifier: String,
|
||||||
let content = UNMutableNotificationContent()
|
userInfo: [String: Any]? = nil,
|
||||||
content.title = title
|
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||||
content.body = body
|
) {
|
||||||
content.sound = .default
|
let content = UNMutableNotificationContent()
|
||||||
if let userInfo = userInfo {
|
content.title = title
|
||||||
content.userInfo = userInfo
|
content.body = body
|
||||||
}
|
content.sound = .default
|
||||||
|
content.interruptionLevel = interruptionLevel
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
if let userInfo = userInfo {
|
||||||
content: content,
|
content.userInfo = userInfo
|
||||||
trigger: nil // Deliver immediately
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { _ in
|
|
||||||
// Notification added
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -61,35 +63,15 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 DM from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteOnlineNotification(nickname: String) {
|
|
||||||
// Send directly without checking app state for favorites
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "⭐ \(nickname) is online!"
|
|
||||||
content.body = "wanna get in there?"
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: "favorite-online-\(UUID().uuidString)",
|
|
||||||
content: content,
|
|
||||||
trigger: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { _ in
|
|
||||||
// Notification added
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geohash public chat notification with deep link to a specific geohash
|
// Geohash public chat notification with deep link to a specific geohash
|
||||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||||
let title = "\(titlePrefix)\(geohash)"
|
let title = "\(titlePrefix)\(geohash)"
|
||||||
@@ -103,25 +85,12 @@ final class NotificationService {
|
|||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
|
|
||||||
// For network notifications, we want to show them even in foreground
|
sendLocalNotification(
|
||||||
// No app state check - let the notification delegate handle presentation
|
title: title,
|
||||||
DispatchQueue.main.async {
|
body: body,
|
||||||
let content = UNMutableNotificationContent()
|
identifier: identifier,
|
||||||
content.title = title
|
interruptionLevel: .timeSensitive
|
||||||
content.body = body
|
)
|
||||||
content.sound = .default
|
|
||||||
content.interruptionLevel = .timeSensitive // Make it more prominent
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
|
||||||
content: content,
|
|
||||||
trigger: nil // Deliver immediately
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { _ in
|
|
||||||
// Notification added
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// NotificationStreamAssembler.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NotificationStreamAssembler {
|
||||||
|
private var buffer = Data()
|
||||||
|
private var pendingFrameStartedAt: DispatchTime?
|
||||||
|
private var pendingFrameExpectedLength: Int = 0
|
||||||
|
|
||||||
|
private mutating func resetState() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
|
|
||||||
|
buffer.append(chunk)
|
||||||
|
|
||||||
|
var frames: [Data] = []
|
||||||
|
var dropped: [UInt8] = []
|
||||||
|
var didReset = false
|
||||||
|
let now = DispatchTime.now()
|
||||||
|
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||||
|
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||||
|
|
||||||
|
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||||
|
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||||
|
resetState()
|
||||||
|
return ([], [], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
while buffer.count >= minimumFramePrefix {
|
||||||
|
guard let version = buffer.first else { break }
|
||||||
|
guard version == 1 || version == 2 else {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
||||||
|
guard buffer.count >= framePrefix else { break }
|
||||||
|
|
||||||
|
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||||
|
guard flagsIndex < buffer.endIndex else { break }
|
||||||
|
let flags = buffer[flagsIndex]
|
||||||
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
|
|
||||||
|
let lengthOffset = 12
|
||||||
|
let payloadLength: Int
|
||||||
|
if version == 2 {
|
||||||
|
let lengthIndex = buffer.startIndex + lengthOffset
|
||||||
|
payloadLength =
|
||||||
|
(Int(buffer[lengthIndex]) << 24) |
|
||||||
|
(Int(buffer[lengthIndex + 1]) << 16) |
|
||||||
|
(Int(buffer[lengthIndex + 2]) << 8) |
|
||||||
|
Int(buffer[lengthIndex + 3])
|
||||||
|
} else {
|
||||||
|
let lengthIndex = buffer.startIndex + lengthOffset
|
||||||
|
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameLength = framePrefix + payloadLength
|
||||||
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
if isCompressed {
|
||||||
|
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||||
|
if payloadLength < rawLengthFieldBytes {
|
||||||
|
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||||
|
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer.count < frameLength {
|
||||||
|
let remaining = frameLength - buffer.count
|
||||||
|
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||||
|
pendingFrameStartedAt = now
|
||||||
|
pendingFrameExpectedLength = frameLength
|
||||||
|
} else if let started = pendingFrameStartedAt {
|
||||||
|
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
||||||
|
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
||||||
|
if elapsed >= threshold {
|
||||||
|
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
|
||||||
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
|
frames.append(frame)
|
||||||
|
buffer.removeFirst(frameLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (frames, dropped, didReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Manages all private chat functionality
|
/// Manages all private chat functionality
|
||||||
final class PrivateChatManager: ObservableObject {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: String? = nil
|
@Published var selectedPeer: PeerID? = nil
|
||||||
@Published var unreadMessages: Set<String> = []
|
@Published var unreadMessages: Set<PeerID> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -31,7 +31,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
@@ -53,109 +53,33 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeer = nil
|
selectedPeer = nil
|
||||||
selectedPeerFingerprint = nil
|
selectedPeerFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a private message
|
|
||||||
func sendMessage(_ content: String, to peerID: String) {
|
|
||||||
guard let meshService = meshService,
|
|
||||||
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageID = UUID().uuidString
|
|
||||||
|
|
||||||
// Create local message
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: meshService.myNickname,
|
|
||||||
content: content,
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: peerNickname,
|
|
||||||
senderPeerID: meshService.myPeerID,
|
|
||||||
mentions: nil,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add to chat
|
|
||||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
|
||||||
privateChats[peerID]?.append(message)
|
|
||||||
// Enforce per-chat cap on local append
|
|
||||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
|
||||||
let remove = arr.count - privateChatCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
privateChats[peerID] = arr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send via mesh service
|
|
||||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle incoming private message
|
|
||||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
|
||||||
guard let senderPeerID = message.senderPeerID else { return }
|
|
||||||
|
|
||||||
// Initialize chat if needed
|
|
||||||
if privateChats[senderPeerID] == nil {
|
|
||||||
privateChats[senderPeerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate by ID: replace existing message if present, else append
|
|
||||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
privateChats[senderPeerID]?[idx] = message
|
|
||||||
} else {
|
|
||||||
privateChats[senderPeerID]?.append(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
|
||||||
sanitizeChat(for: senderPeerID)
|
|
||||||
// Enforce cap after sanitize
|
|
||||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
|
||||||
let remove = arr.count - privateChatCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
privateChats[senderPeerID] = arr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as unread if not in this chat
|
|
||||||
if selectedPeer != senderPeerID {
|
|
||||||
unreadMessages.insert(senderPeerID)
|
|
||||||
|
|
||||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
|
||||||
if !sentReadReceipts.contains(message.id) {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
|
||||||
from: message.sender,
|
|
||||||
message: message.content,
|
|
||||||
peerID: senderPeerID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Send read receipt if viewing this chat
|
|
||||||
sendReadReceipt(for: message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sanitizeChat(for peerID: String) {
|
func sanitizeChat(for peerID: PeerID) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
guard let arr = privateChats[peerID] else { return }
|
||||||
var seen = Set<String>()
|
if arr.count <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexByID: [String: Int] = [:]
|
||||||
|
indexByID.reserveCapacity(arr.count)
|
||||||
var deduped: [BitchatMessage] = []
|
var deduped: [BitchatMessage] = []
|
||||||
|
deduped.reserveCapacity(arr.count)
|
||||||
|
|
||||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
if !seen.contains(msg.id) {
|
if let existing = indexByID[msg.id] {
|
||||||
seen.insert(msg.id)
|
deduped[existing] = msg
|
||||||
deduped.append(msg)
|
|
||||||
} else {
|
} else {
|
||||||
// Replace previous with the latest occurrence (which is later in sort)
|
indexByID[msg.id] = deduped.count
|
||||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
deduped.append(msg)
|
||||||
deduped[index] = msg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats[peerID] = deduped
|
privateChats[peerID] = deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
func markAsRead(from peerID: String) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
unreadMessages.remove(peerID)
|
unreadMessages.remove(peerID)
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
@@ -168,48 +92,6 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
|
||||||
func updateSelectedPeer(peers: [String: String]) {
|
|
||||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
|
||||||
|
|
||||||
// Find peer with matching fingerprint
|
|
||||||
for (peerID, _) in peers {
|
|
||||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
|
||||||
selectedPeer = peerID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get chat messages for current context
|
|
||||||
func getCurrentMessages() -> [BitchatMessage] {
|
|
||||||
guard let peer = selectedPeer else { return [] }
|
|
||||||
return privateChats[peer] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear a private chat
|
|
||||||
func clearChat(with peerID: String) {
|
|
||||||
privateChats[peerID]?.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle delivery acknowledgment
|
|
||||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
|
||||||
guard privateChats[peerID] != nil else { return }
|
|
||||||
|
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle read receipt
|
|
||||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
|
||||||
guard privateChats[peerID] != nil else { return }
|
|
||||||
|
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
@@ -223,13 +105,13 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? "",
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct RelayController {
|
|||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
|
isFragment: Bool,
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
@@ -36,6 +37,16 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isFragment {
|
||||||
|
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
||||||
|
guard ttlLimit > 1 else {
|
||||||
|
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
||||||
|
}
|
||||||
|
let newTTL = ttlLimit &- 1
|
||||||
|
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
|
||||||
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Announces get a bit more headroom
|
// - Announces get a bit more headroom
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Centralized progress bus for Bluetooth file transfers.
|
||||||
|
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
|
||||||
|
final class TransferProgressManager {
|
||||||
|
static let shared = TransferProgressManager()
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case started(id: String, totalFragments: Int)
|
||||||
|
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
case completed(id: String, totalFragments: Int)
|
||||||
|
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let subject = PassthroughSubject<Event, Never>()
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
|
||||||
|
private var states: [String: (sent: Int, total: Int)] = [:]
|
||||||
|
|
||||||
|
var publisher: AnyPublisher<Event, Never> {
|
||||||
|
subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(id: String, totalFragments: Int) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.states[id] = (sent: 0, total: totalFragments)
|
||||||
|
self.subject.send(.started(id: id, totalFragments: totalFragments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordFragmentSent(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self, var state = self.states[id] else { return }
|
||||||
|
state.sent = min(state.sent + 1, state.total)
|
||||||
|
self.states[id] = state
|
||||||
|
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
if state.sent >= state.total {
|
||||||
|
self.states.removeValue(forKey: id)
|
||||||
|
self.subject.send(.completed(id: id, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
|
||||||
|
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.states.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||||
|
var result: (sent: Int, total: Int)?
|
||||||
|
queue.sync {
|
||||||
|
result = states[id]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import Combine
|
|||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||||
let id: String
|
let peerID: PeerID
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let noisePublicKey: Data?
|
let noisePublicKey: Data?
|
||||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Peer events (preferred over publishers for UI)
|
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
// Peer events (preferred over publishers for UI)
|
||||||
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
|
||||||
|
// Peer snapshots (for non-UI services)
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
var myPeerID: String { get }
|
var myPeerID: PeerID { get }
|
||||||
var myNickname: String { get }
|
var myNickname: String { get }
|
||||||
func setNickname(_ nickname: String)
|
func setNickname(_ nickname: String)
|
||||||
|
|
||||||
@@ -28,37 +32,44 @@ protocol Transport: AnyObject {
|
|||||||
func emergencyDisconnectAll()
|
func emergencyDisconnectAll()
|
||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
func isPeerReachable(_ peerID: String) -> Bool
|
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: PeerID) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [PeerID: String]
|
||||||
|
|
||||||
// Protocol utilities
|
// Protocol utilities
|
||||||
func getFingerprint(for peerID: String) -> String?
|
func getFingerprint(for peerID: PeerID) -> String?
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||||
func triggerHandshake(with peerID: String)
|
func triggerHandshake(with peerID: PeerID)
|
||||||
func getNoiseService() -> NoiseEncryptionService
|
func getNoiseService() -> NoiseEncryptionService
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||||
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
sendMessage(content, mentions: mentions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ enum TransportConfig {
|
|||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
|
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||||
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
|
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
||||||
|
|
||||||
// UI / Storage Caps
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
@@ -30,7 +34,11 @@ enum TransportConfig {
|
|||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
static let bleConnectionCandidatesMax: Int = 100
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||||
static let blePendingNotificationsCapCount: Int = 20
|
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
||||||
|
static let bleAssemblerStallResetMs: Int = 250
|
||||||
|
static let blePendingNotificationsCapCount: Int = 128
|
||||||
|
static let bleNotificationRetryDelayMs: Int = 25
|
||||||
|
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
@@ -62,6 +70,7 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
|
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
@@ -141,6 +150,9 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Geo relay directory
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
|
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
||||||
|
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
||||||
|
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
||||||
|
|
||||||
// BLE operational delays
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -19,15 +18,16 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published private(set) var peers: [BitchatPeer] = []
|
@Published private(set) var peers: [BitchatPeer] = []
|
||||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
||||||
@Published private(set) var favorites: [BitchatPeer] = []
|
@Published private(set) var favorites: [BitchatPeer] = []
|
||||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private var peerIndex: [String: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
@@ -35,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
meshService: Transport,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
@@ -78,12 +83,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<PeerID> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<PeerID> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.peerID
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
@@ -104,7 +109,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 2: Add offline favorites that we actively favorite
|
// Phase 2: Add offline favorites that we actively favorite
|
||||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
let peerID = favoriteKey.hexEncodedString()
|
let peerID = PeerID(hexData: favoriteKey)
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
// Skip if already added (connected peer)
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
if addedPeerIDs.contains(peerID) { continue }
|
||||||
@@ -138,10 +143,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Phase 4: Build subsets and indices
|
// Phase 4: Build subsets and indices
|
||||||
var favoritesList: [BitchatPeer] = []
|
var favoritesList: [BitchatPeer] = []
|
||||||
var mutualsList: [BitchatPeer] = []
|
var mutualsList: [BitchatPeer] = []
|
||||||
var newIndex: [String: BitchatPeer] = [:]
|
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||||
|
|
||||||
for peer in enrichedPeers {
|
for peer in enrichedPeers {
|
||||||
newIndex[peer.id] = peer
|
newIndex[peer.peerID] = peer
|
||||||
|
|
||||||
if peer.isFavorite {
|
if peer.isFavorite {
|
||||||
favoritesList.append(peer)
|
favoritesList.append(peer)
|
||||||
@@ -185,7 +190,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
peerID: peerInfo.peerID,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
@@ -205,10 +210,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromFavorite(
|
private func buildPeerFromFavorite(
|
||||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||||
peerID: String
|
peerID: PeerID
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerID,
|
peerID: peerID,
|
||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
@@ -225,27 +230,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Get peer by ID
|
/// Get peer by ID
|
||||||
func getPeer(by id: String) -> BitchatPeer? {
|
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||||
return peerIndex[id]
|
return peerIndex[peerID]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> String? {
|
func getPeerID(for nickname: String) -> PeerID? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.id
|
return peer.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if peer is online
|
|
||||||
func isOnline(_ peerID: String) -> Bool {
|
|
||||||
return connectedPeerIDs.contains(peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if peer is blocked
|
/// Check if peer is blocked
|
||||||
func isBlocked(_ peerID: String) -> Bool {
|
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||||
// Get fingerprint
|
// Get fingerprint
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||||
|
|
||||||
@@ -258,8 +258,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle favorite status
|
/// Toggle favorite status
|
||||||
func toggleFavorite(_ peerID: String) {
|
func toggleFavorite(_ peerID: PeerID) {
|
||||||
guard let peer = getPeer(by: peerID) else {
|
guard let peer = getPeer(by: peerID) else {
|
||||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
@@ -322,39 +322,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle blocked status
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
func toggleBlocked(_ peerID: String) {
|
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
|
||||||
|
|
||||||
// Get or create social identity
|
|
||||||
var identity = identityManager.getSocialIdentity(for: fingerprint)
|
|
||||||
?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Toggle blocked status
|
|
||||||
identity.isBlocked = !identity.isBlocked
|
|
||||||
|
|
||||||
// Can't be both favorite and blocked
|
|
||||||
if identity.isBlocked {
|
|
||||||
identity.isFavorite = false
|
|
||||||
// Also remove from favorites service
|
|
||||||
if let peer = getPeer(by: peerID) {
|
|
||||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identityManager.updateSocialIdentity(identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get fingerprint for peer ID
|
|
||||||
func getFingerprint(for peerID: String) -> String? {
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let cached = fingerprintCache[peerID] {
|
if let cached = fingerprintCache[peerID] {
|
||||||
return cached
|
return cached
|
||||||
@@ -379,23 +347,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
var blockedUsers: Set<String> {
|
var blockedUsers: Set<String> {
|
||||||
Set(peers.compactMap { peer in
|
Set(peers.compactMap { peer in
|
||||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Extensions
|
|
||||||
|
|
||||||
extension Data {
|
|
||||||
func sha256Fingerprint() -> String {
|
|
||||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
|
||||||
let hash = SHA256.hash(data: self)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
|||||||
nickname: payload.nickname,
|
nickname: payload.nickname,
|
||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
nonceB64: payload.nonceB64,
|
nonceB64: payload.nonceB64,
|
||||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
sigHex: sig.hexEncodedString())
|
||||||
let out = signed.toURLString()
|
let out = signed.toURLString()
|
||||||
Cache.last = (nickname, npub, Date(), out)
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -4,39 +4,114 @@ import Foundation
|
|||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
protocol Delegate: AnyObject {
|
protocol Delegate: AnyObject {
|
||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PacketStore {
|
||||||
|
private(set) var packets: [String: BitchatPacket] = [:]
|
||||||
|
private(set) var order: [String] = []
|
||||||
|
|
||||||
|
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
|
||||||
|
guard capacity > 0 else { return }
|
||||||
|
if packets[idHex] != nil {
|
||||||
|
packets[idHex] = packet
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packets[idHex] = packet
|
||||||
|
order.append(idHex)
|
||||||
|
while order.count > capacity {
|
||||||
|
let victim = order.removeFirst()
|
||||||
|
packets.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
|
||||||
|
order.compactMap { key in
|
||||||
|
guard let packet = packets[key], isFresh(packet) else { return nil }
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
|
||||||
|
var nextOrder: [String] = []
|
||||||
|
for key in order {
|
||||||
|
guard let packet = packets[key] else { continue }
|
||||||
|
if shouldRemove(packet) {
|
||||||
|
packets.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
nextOrder.append(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order = nextOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
|
||||||
|
remove { !isFresh($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SyncSchedule {
|
||||||
|
let types: SyncTypeFlags
|
||||||
|
let interval: TimeInterval
|
||||||
|
var lastSent: Date
|
||||||
|
}
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
|
var fragmentCapacity: Int = 600
|
||||||
|
var fileTransferCapacity: Int = 200
|
||||||
|
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||||
|
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
|
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: String
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
private var messages = PacketStore()
|
||||||
private var messageOrder: [String] = []
|
private var fragments = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
private var fileTransfers = PacketStore()
|
||||||
|
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
|
private var syncSchedules: [SyncSchedule] = []
|
||||||
|
|
||||||
init(myPeerID: String, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
|
var schedules: [SyncSchedule] = []
|
||||||
|
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||||
|
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
|
}
|
||||||
|
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||||
|
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
|
}
|
||||||
|
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
|
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
|
}
|
||||||
|
syncSchedules = schedules
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
||||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.performPeriodicMaintenance()
|
||||||
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
}
|
}
|
||||||
@@ -45,9 +120,20 @@ final class GossipSyncManager {
|
|||||||
periodicTimer?.cancel(); periodicTimer = nil
|
periodicTimer?.cancel(); periodicTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
self?.sendRequestSync(to: peerID)
|
guard let self = self else { return }
|
||||||
|
self.sendRequestSync(to: peerID, types: .publicMessages)
|
||||||
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
|
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID, types: .fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
|
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,40 +143,70 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to check if a packet is within the age threshold
|
||||||
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||||
|
|
||||||
|
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||||
|
guard nowMs >= ageThresholdMs else { return true }
|
||||||
|
|
||||||
|
let cutoffMs = nowMs - ageThresholdMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return true }
|
||||||
|
let cutoffMs = nowMs - timeoutMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
}()
|
}()
|
||||||
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
|
||||||
let isAnnounce = (mt == .announce)
|
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
switch messageType {
|
||||||
|
case .announce:
|
||||||
if isBroadcastMessage {
|
guard isPacketFresh(packet) else { return }
|
||||||
if messages[idHex] == nil {
|
guard isAnnouncementFresh(packet) else {
|
||||||
messages[idHex] = packet
|
let sender = PeerID(hexData: packet.senderID)
|
||||||
messageOrder.append(idHex)
|
removeState(for: sender)
|
||||||
// Enforce capacity
|
return
|
||||||
let cap = max(1, config.seenCapacity)
|
|
||||||
while messageOrder.count > cap {
|
|
||||||
let victim = messageOrder.removeFirst()
|
|
||||||
messages.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if isAnnounce {
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
let sender = packet.senderID.hexEncodedString()
|
let sender = PeerID(hexData: packet.senderID)
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
|
case .message:
|
||||||
|
guard isBroadcastRecipient else { return }
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||||
|
case .fragment:
|
||||||
|
guard isBroadcastRecipient else { return }
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
|
||||||
|
case .fileTransfer:
|
||||||
|
guard isBroadcastRecipient else { return }
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync() {
|
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: nil, // broadcast
|
recipientID: nil, // broadcast
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -101,10 +217,10 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: String) {
|
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
let hexByte = String(temp.prefix(2))
|
let hexByte = String(temp.prefix(2))
|
||||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||||
@@ -112,7 +228,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: recipient,
|
recipientID: recipient,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -123,13 +239,14 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(to: peerID, packet: signed)
|
delegate?.sendPacket(to: peerID, packet: signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
self?._handleRequestSync(from: peerID, request: request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
|
let requestedTypes = (request.types ?? .publicMessages)
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -137,74 +254,182 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them
|
if requestedTypes.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
guard isPacketFresh(pkt) else { continue }
|
||||||
if !mightContain(idBytes) {
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
var toSend = pkt
|
if !mightContain(idBytes) {
|
||||||
toSend.ttl = 0
|
var toSend = pkt
|
||||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing
|
if requestedTypes.contains(.message) {
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestedTypes.contains(.fragment) {
|
||||||
|
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||||
|
for pkt in frags {
|
||||||
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestedTypes.contains(.fileTransfer) {
|
||||||
|
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||||
|
for pkt in files {
|
||||||
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages
|
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
if types.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
candidates.append(pair.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if types.contains(.message) {
|
||||||
|
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
||||||
|
}
|
||||||
|
if types.contains(.fragment) {
|
||||||
|
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
|
||||||
|
}
|
||||||
|
if types.contains(.fileTransfer) {
|
||||||
|
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||||
|
}
|
||||||
|
if candidates.isEmpty {
|
||||||
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
|
return req.encode()
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by timestamp desc
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
let cap = max(1, config.seenCapacity)
|
let cap: Int
|
||||||
|
if types == .fragment {
|
||||||
|
cap = max(1, config.fragmentCapacity)
|
||||||
|
} else if types == .fileTransfer {
|
||||||
|
cap = max(1, config.fileTransferCapacity)
|
||||||
|
} else {
|
||||||
|
cap = max(1, config.seenCapacity)
|
||||||
|
}
|
||||||
let takeN = min(candidates.count, min(nMax, cap))
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
if takeN <= 0 {
|
if takeN <= 0 {
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Periodic cleanup of expired messages and announcements
|
||||||
func removeAnnouncementForPeer(_ peerID: String) {
|
private func cleanupExpiredMessages() {
|
||||||
queue.async { [weak self] in
|
// Remove expired announcements
|
||||||
self?._removeAnnouncementForPeer(peerID)
|
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.removeExpired(isFresh: isPacketFresh)
|
||||||
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
|
cleanupExpiredMessages()
|
||||||
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
|
for index in syncSchedules.indices {
|
||||||
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
|
syncSchedules[index].lastSent = now
|
||||||
|
sendRequestSync(for: syncSchedules[index].types)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: String) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
let normalizedPeerID = peerID.lowercased()
|
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
return
|
||||||
|
|
||||||
// Remove messages from this peer
|
|
||||||
// Collect IDs to remove first to avoid concurrent modification
|
|
||||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
|
||||||
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
|
|
||||||
}
|
}
|
||||||
|
lastStalePeerCleanup = now
|
||||||
// Remove messages and update messageOrder
|
cleanupStaleAnnouncements(now: now)
|
||||||
for id in messageIdsToRemove {
|
}
|
||||||
messages.removeValue(forKey: id)
|
|
||||||
messageOrder.removeAll { $0 == id }
|
private func cleanupStaleAnnouncements(now: Date) {
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return }
|
||||||
|
let cutoff = nowMs - timeoutMs
|
||||||
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||||
|
pair.packet.timestamp < cutoff ? peerID : nil
|
||||||
|
}
|
||||||
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
|
for peerKey in stalePeerIDs {
|
||||||
|
removeState(for: peerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.removeState(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeState(for peerID: PeerID) {
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
|
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension GossipSyncManager {
|
||||||
|
func _performMaintenanceSynchronously(now: Date = Date()) {
|
||||||
|
queue.sync {
|
||||||
|
performPeriodicMaintenance(now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
latestAnnouncementByPeer[peerID] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
|
queue.sync {
|
||||||
|
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
|||||||
let digest = hasher.finalize()
|
let digest = hasher.finalize()
|
||||||
return Data(digest.prefix(16))
|
return Data(digest.prefix(16))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
|
||||||
return computeId(packet).hexEncodedString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||||
|
/// Matches the Android mapping (bit index -> message type).
|
||||||
|
struct SyncTypeFlags: OptionSet {
|
||||||
|
let rawValue: UInt64
|
||||||
|
|
||||||
|
init(rawValue: UInt64) {
|
||||||
|
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bitIndex(for type: MessageType) -> Int? {
|
||||||
|
switch type {
|
||||||
|
case .announce: return 0
|
||||||
|
case .message: return 1
|
||||||
|
case .leave: return 2
|
||||||
|
case .noiseHandshake: return 3
|
||||||
|
case .noiseEncrypted: return 4
|
||||||
|
case .fragment: return 5
|
||||||
|
case .requestSync: return 6
|
||||||
|
case .fileTransfer: return 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func type(forBit index: Int) -> MessageType? {
|
||||||
|
switch index {
|
||||||
|
case 0: return .announce
|
||||||
|
case 1: return .message
|
||||||
|
case 2: return .leave
|
||||||
|
case 3: return .noiseHandshake
|
||||||
|
case 4: return .noiseEncrypted
|
||||||
|
case 5: return .fragment
|
||||||
|
case 6: return .requestSync
|
||||||
|
case 7: return .fileTransfer
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let announce = SyncTypeFlags(messageTypes: [.announce])
|
||||||
|
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||||
|
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||||
|
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||||
|
|
||||||
|
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||||
|
|
||||||
|
init(messageTypes: [MessageType]) {
|
||||||
|
var raw: UInt64 = 0
|
||||||
|
for type in messageTypes {
|
||||||
|
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
|
||||||
|
raw |= (1 << UInt64(bit))
|
||||||
|
}
|
||||||
|
self.init(rawValue: raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ type: MessageType) -> Bool {
|
||||||
|
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
|
||||||
|
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
||||||
|
SyncTypeFlags(rawValue: rawValue | other.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
|
||||||
|
SyncTypeFlags(rawValue: rawValue & other.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMessageTypes() -> [MessageType] {
|
||||||
|
guard rawValue != 0 else { return [] }
|
||||||
|
var types: [MessageType] = []
|
||||||
|
for bit in 0..<64 {
|
||||||
|
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
|
||||||
|
if let type = SyncTypeFlags.type(forBit: bit) {
|
||||||
|
types.append(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func toData() -> Data? {
|
||||||
|
guard rawValue != 0 else { return nil }
|
||||||
|
var value = rawValue
|
||||||
|
var bytes: [UInt8] = []
|
||||||
|
while value > 0 && bytes.count < 8 {
|
||||||
|
bytes.append(UInt8(value & 0xFF))
|
||||||
|
value >>= 8
|
||||||
|
}
|
||||||
|
while let last = bytes.last, last == 0 {
|
||||||
|
bytes.removeLast()
|
||||||
|
}
|
||||||
|
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
|
||||||
|
return Data(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> SyncTypeFlags? {
|
||||||
|
guard (1...8).contains(data.count) else { return nil }
|
||||||
|
var raw: UInt64 = 0
|
||||||
|
for (index, byte) in data.enumerated() {
|
||||||
|
raw |= UInt64(byte) << UInt64(index * 8)
|
||||||
|
}
|
||||||
|
return SyncTypeFlags(rawValue: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,15 +61,13 @@ struct CompressionUtil {
|
|||||||
// 1. Data is too small
|
// 1. Data is too small
|
||||||
// 2. Data appears to be already compressed (high entropy)
|
// 2. Data appears to be already compressed (high entropy)
|
||||||
guard data.count >= compressionThreshold else { return false }
|
guard data.count >= compressionThreshold else { return false }
|
||||||
|
|
||||||
// Simple entropy check - count unique bytes
|
// Quick uniqueness check — a high diversity of bytes usually means the
|
||||||
var byteFrequency = [UInt8: Int]()
|
// payload is already compressed. We only need to know how many unique
|
||||||
for byte in data {
|
// values exist rather than keeping full frequency counts.
|
||||||
byteFrequency[byte, default: 0] += 1
|
let uniqueByteCount = Set(data).count
|
||||||
}
|
let sampleSize = min(data.count, 256)
|
||||||
|
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
||||||
// If we have very high byte diversity, data is likely already compressed
|
|
||||||
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
|
|
||||||
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Data+SHA256.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Created by Islam on 26/09/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import struct Foundation.Data
|
||||||
|
import struct CryptoKit.SHA256
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
/// Returns the hex representation of SHA256 hash
|
||||||
|
func sha256Fingerprint() -> String {
|
||||||
|
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||||
|
sha256Hash().hexEncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the SHA256 hash wrapped in Data
|
||||||
|
func sha256Hash() -> Data {
|
||||||
|
Data(SHA256.hash(data: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||||
|
enum FileTransferLimits {
|
||||||
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Voice notes stay small for low-latency relays.
|
||||||
|
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
|
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||||
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
|
static let maxFramedFileBytes: Int = {
|
||||||
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||||
|
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||||
|
+ BinaryProtocol.senderIDSize
|
||||||
|
+ BinaryProtocol.recipientIDSize
|
||||||
|
+ BinaryProtocol.signatureSize
|
||||||
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
|
size <= maxPayloadBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,19 @@ private extension Font.TextStyle {
|
|||||||
return .caption2
|
return .caption2
|
||||||
case ..<13.0:
|
case ..<13.0:
|
||||||
return .caption
|
return .caption
|
||||||
case ..<14.0:
|
case ..<13.75:
|
||||||
return .footnote
|
return .footnote
|
||||||
case ..<16.5:
|
case ..<15.5:
|
||||||
|
return .subheadline
|
||||||
|
case ..<17.5:
|
||||||
|
return .callout
|
||||||
|
case ..<19.5:
|
||||||
return .body
|
return .body
|
||||||
case ..<19.0:
|
case ..<22.5:
|
||||||
return .title3
|
return .title3
|
||||||
case ..<23.0:
|
case ..<27.5:
|
||||||
return .title2
|
return .title2
|
||||||
case ..<30.0:
|
case ..<34.0:
|
||||||
return .title
|
return .title
|
||||||
default:
|
default:
|
||||||
return .largeTitle
|
return .largeTitle
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
/// Comprehensive input validation for BitChat protocol
|
/// Comprehensive input validation for BitChat protocol
|
||||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||||
@@ -11,88 +12,45 @@ struct InputValidator {
|
|||||||
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||||
// for headers/padding by limiting user content to 60_000 bytes.
|
// for headers/padding by limiting user content to 60_000 bytes.
|
||||||
static let maxMessageLength = 60_000
|
static let maxMessageLength = 60_000
|
||||||
static let maxReasonLength = 200
|
|
||||||
static let maxPeerIDLength = 64
|
|
||||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer ID Validation
|
|
||||||
|
|
||||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
// Accept short routing IDs (exact 16-hex)
|
|
||||||
if PeerIDResolver.isShortID(peerID) { return true }
|
|
||||||
// If length equals short-hex length but isn't valid hex, reject
|
|
||||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
|
||||||
// Accept full Noise key hex (exact 64-hex)
|
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
|
||||||
// If length equals full key length but isn't valid hex, reject
|
|
||||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
|
||||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
|
||||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
return !peerID.isEmpty &&
|
|
||||||
peerID.count < Limits.maxPeerIDLength &&
|
|
||||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings (nicknames, messages)
|
/// Validates and sanitizes user-provided strings used in UI
|
||||||
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
|
///
|
||||||
// Check empty
|
/// Rejects strings containing control characters to prevent potential security issues
|
||||||
guard !string.isEmpty else { return nil }
|
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
||||||
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
// Trim whitespace
|
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
// Check length
|
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Remove control characters except allowed ones
|
// Reject control characters outright instead of rewriting the string.
|
||||||
var allowedControlChars = CharacterSet()
|
// This prevents injection attacks and ensures consistent UI rendering.
|
||||||
if allowNewlines {
|
let controlChars = CharacterSet.controlCharacters
|
||||||
allowedControlChars.insert(charactersIn: "\n\r")
|
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
||||||
|
// Log rejection for monitoring, without exposing actual content for privacy
|
||||||
|
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
return trimmed
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
|
||||||
|
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
|
||||||
guard cleaned.data(using: .utf8) != nil else { return nil }
|
|
||||||
|
|
||||||
// Prevent zero-width characters and other invisible unicode
|
|
||||||
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
|
||||||
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
|
||||||
|
|
||||||
return visible.isEmpty ? nil : visible
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
static func validateNickname(_ nickname: String) -> String? {
|
static func validateNickname(_ nickname: String) -> String? {
|
||||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
|
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates message content
|
|
||||||
static func validateMessageContent(_ content: String) -> String? {
|
|
||||||
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates error/reason strings
|
|
||||||
static func validateReasonString(_ reason: String) -> String? {
|
|
||||||
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Field Validation
|
// MARK: - Protocol Field Validation
|
||||||
|
|
||||||
// Note: Message type validation is performed closer to decoding using
|
// Note: Message type validation is performed closer to decoding using
|
||||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||||
|
|
||||||
/// Validates hop count is reasonable
|
|
||||||
static func validateHopCount(_ hopCount: UInt8) -> Bool {
|
|
||||||
return hopCount <= 10 // Prevent excessive forwarding
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates timestamp is reasonable (not too far in past or future)
|
/// Validates timestamp is reasonable (not too far in past or future)
|
||||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -100,38 +58,5 @@ struct InputValidator {
|
|||||||
let oneHourFromNow = now.addingTimeInterval(3600)
|
let oneHourFromNow = now.addingTimeInterval(3600)
|
||||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates data size for different contexts
|
|
||||||
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
|
|
||||||
return data.count > 0 && data.count <= maxSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Binary Data Validation
|
|
||||||
|
|
||||||
/// Validates UUID format
|
|
||||||
static func validateUUID(_ uuid: String) -> Bool {
|
|
||||||
// Remove dashes and validate hex
|
|
||||||
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
|
|
||||||
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates public key data
|
|
||||||
static func validatePublicKey(_ keyData: Data) -> Bool {
|
|
||||||
// Curve25519 public keys are 32 bytes
|
|
||||||
return keyData.count == 32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates signature data
|
|
||||||
static func validateSignature(_ signature: Data) -> Bool {
|
|
||||||
// Ed25519 signatures are 64 bytes
|
|
||||||
return signature.count == 64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Character Extensions
|
|
||||||
|
|
||||||
private extension Character {
|
|
||||||
var isHexDigit: Bool {
|
|
||||||
return "0123456789abcdefABCDEF".contains(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Foundation
|
|||||||
struct PeerDisplayNameResolver {
|
struct PeerDisplayNameResolver {
|
||||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
/// - peers: Array of tuples (peerID, nickname, isConnected).
|
||||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||||
/// - Returns: Map of peerID -> displayName.
|
/// - Returns: Map of peerID -> displayName.
|
||||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
|
||||||
// Count collisions among connected peers and include our own nickname
|
// Count collisions among connected peers and include our own nickname
|
||||||
var counts: [String: Int] = [:]
|
var counts: [String: Int] = [:]
|
||||||
for p in peers where p.isConnected {
|
for p in peers where p.isConnected {
|
||||||
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
|
|||||||
}
|
}
|
||||||
counts[selfNickname, default: 0] += 1
|
counts[selfNickname, default: 0] += 1
|
||||||
|
|
||||||
var result: [String: String] = [:]
|
var result: [PeerID: String] = [:]
|
||||||
for p in peers {
|
for p in peers {
|
||||||
var name = p.nickname
|
var name = p.nickname
|
||||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||||
name += "#" + String(p.id.prefix(4))
|
name += "#" + String(p.peerID.id.prefix(4))
|
||||||
}
|
}
|
||||||
result[p.id] = name
|
result[p.peerID] = name
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct PeerIDResolver {
|
|
||||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
|
||||||
static func toShortID(_ id: String) -> String {
|
|
||||||
if id.count == 64, let data = Data(hexString: id) {
|
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isShortID(_ id: String) -> Bool {
|
|
||||||
return id.count == 16 && Data(hexString: id) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
|
||||||
return id.count == 64 && Data(hexString: id) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+1213
-1280
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// GeoChannelCoordinator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Centralizes Combine wiring for location channel selection and sampling.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GeoChannelCoordinator {
|
||||||
|
private let locationManager: LocationChannelManager
|
||||||
|
private let bookmarksStore: GeohashBookmarksStore
|
||||||
|
private let torManager: TorManager
|
||||||
|
|
||||||
|
private let onChannelSwitch: (ChannelID) -> Void
|
||||||
|
private let beginSampling: ([String]) -> Void
|
||||||
|
private let endSampling: () -> Void
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var regionalGeohashes: [String] = []
|
||||||
|
private var bookmarkedGeohashes: [String] = []
|
||||||
|
|
||||||
|
init(
|
||||||
|
locationManager: LocationChannelManager? = nil,
|
||||||
|
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||||
|
torManager: TorManager? = nil,
|
||||||
|
onChannelSwitch: @escaping (ChannelID) -> Void,
|
||||||
|
beginSampling: @escaping ([String]) -> Void,
|
||||||
|
endSampling: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
||||||
|
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
||||||
|
self.torManager = torManager ?? Self.defaultTorManager()
|
||||||
|
self.onChannelSwitch = onChannelSwitch
|
||||||
|
self.beginSampling = beginSampling
|
||||||
|
self.endSampling = endSampling
|
||||||
|
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
|
||||||
|
bookmarkedGeohashes = bookmarksStore.bookmarks
|
||||||
|
|
||||||
|
locationManager.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.onChannelSwitch(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationManager.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] channels in
|
||||||
|
guard let self else { return }
|
||||||
|
self.regionalGeohashes = channels.map { $0.geohash }
|
||||||
|
self.updateSampling()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
bookmarksStore.$bookmarks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] bookmarks in
|
||||||
|
guard let self else { return }
|
||||||
|
self.bookmarkedGeohashes = bookmarks
|
||||||
|
self.updateSampling()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationManager.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] state in
|
||||||
|
guard let self, state == .authorized else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.locationManager.refreshChannels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
self.onChannelSwitch(self.locationManager.selectedChannel)
|
||||||
|
}
|
||||||
|
updateSampling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSampling() {
|
||||||
|
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !union.isEmpty else {
|
||||||
|
endSampling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if torManager.isForeground() {
|
||||||
|
beginSampling(union)
|
||||||
|
} else {
|
||||||
|
endSampling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSampling() {
|
||||||
|
updateSampling()
|
||||||
|
}
|
||||||
|
private static func defaultLocationManager() -> LocationChannelManager {
|
||||||
|
LocationChannelManager.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func defaultTorManager() -> TorManager {
|
||||||
|
TorManager.shared
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// MessageRateLimiter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Handles per-sender and per-content token buckets for public message intake.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MessageRateLimiter {
|
||||||
|
private struct TokenBucket {
|
||||||
|
var capacity: Double
|
||||||
|
var tokens: Double
|
||||||
|
var refillPerSec: Double
|
||||||
|
var lastRefill: Date
|
||||||
|
|
||||||
|
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
||||||
|
let dt = now.timeIntervalSince(lastRefill)
|
||||||
|
if dt > 0 {
|
||||||
|
tokens = min(capacity, tokens + dt * refillPerSec)
|
||||||
|
lastRefill = now
|
||||||
|
}
|
||||||
|
if tokens >= cost {
|
||||||
|
tokens -= cost
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var senderBuckets: [String: TokenBucket] = [:]
|
||||||
|
private var contentBuckets: [String: TokenBucket] = [:]
|
||||||
|
|
||||||
|
private let senderCapacity: Double
|
||||||
|
private let senderRefill: Double
|
||||||
|
private let contentCapacity: Double
|
||||||
|
private let contentRefill: Double
|
||||||
|
|
||||||
|
init(
|
||||||
|
senderCapacity: Double,
|
||||||
|
senderRefillPerSec: Double,
|
||||||
|
contentCapacity: Double,
|
||||||
|
contentRefillPerSec: Double
|
||||||
|
) {
|
||||||
|
self.senderCapacity = senderCapacity
|
||||||
|
self.senderRefill = senderRefillPerSec
|
||||||
|
self.contentCapacity = contentCapacity
|
||||||
|
self.contentRefill = contentRefillPerSec
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
||||||
|
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||||
|
capacity: senderCapacity,
|
||||||
|
tokens: senderCapacity,
|
||||||
|
refillPerSec: senderRefill,
|
||||||
|
lastRefill: now
|
||||||
|
)
|
||||||
|
let senderAllowed = senderBucket.allow(now: now)
|
||||||
|
senderBuckets[senderKey] = senderBucket
|
||||||
|
|
||||||
|
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||||
|
capacity: contentCapacity,
|
||||||
|
tokens: contentCapacity,
|
||||||
|
refillPerSec: contentRefill,
|
||||||
|
lastRefill: now
|
||||||
|
)
|
||||||
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
|
return senderAllowed && contentAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
senderBuckets.removeAll()
|
||||||
|
contentBuckets.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//
|
||||||
|
// MinimalDistancePalette.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Lightweight palette generator that keeps peer colors evenly spaced.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class MinimalDistancePalette {
|
||||||
|
struct Config {
|
||||||
|
let slotCount: Int
|
||||||
|
let avoidCenterHue: Double
|
||||||
|
let avoidHueDelta: Double
|
||||||
|
let saturationLight: Double
|
||||||
|
let saturationDark: Double
|
||||||
|
let baseBrightnessLight: Double
|
||||||
|
let baseBrightnessDark: Double
|
||||||
|
let ringBrightnessDeltaLight: Double
|
||||||
|
let ringBrightnessDeltaDark: Double
|
||||||
|
let preferredBiasWeight: Double
|
||||||
|
let goldenStep: Int
|
||||||
|
|
||||||
|
init(
|
||||||
|
slotCount: Int,
|
||||||
|
avoidCenterHue: Double,
|
||||||
|
avoidHueDelta: Double,
|
||||||
|
saturationLight: Double,
|
||||||
|
saturationDark: Double,
|
||||||
|
baseBrightnessLight: Double,
|
||||||
|
baseBrightnessDark: Double,
|
||||||
|
ringBrightnessDeltaLight: Double,
|
||||||
|
ringBrightnessDeltaDark: Double,
|
||||||
|
preferredBiasWeight: Double = 0.05,
|
||||||
|
goldenStep: Int = 7
|
||||||
|
) {
|
||||||
|
self.slotCount = slotCount
|
||||||
|
self.avoidCenterHue = avoidCenterHue
|
||||||
|
self.avoidHueDelta = avoidHueDelta
|
||||||
|
self.saturationLight = saturationLight
|
||||||
|
self.saturationDark = saturationDark
|
||||||
|
self.baseBrightnessLight = baseBrightnessLight
|
||||||
|
self.baseBrightnessDark = baseBrightnessDark
|
||||||
|
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
|
||||||
|
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
|
||||||
|
self.preferredBiasWeight = preferredBiasWeight
|
||||||
|
self.goldenStep = goldenStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Entry {
|
||||||
|
let slot: Int
|
||||||
|
let ring: Int
|
||||||
|
let hue: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private let config: Config
|
||||||
|
private var currentSeeds: [String: String] = [:]
|
||||||
|
private var entries: [String: Entry] = [:]
|
||||||
|
private var previousEntries: [String: Entry] = [:]
|
||||||
|
|
||||||
|
init(config: Config) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func ensurePalette(for seeds: [String: String]) {
|
||||||
|
guard seeds != currentSeeds || entries.count != seeds.count else { return }
|
||||||
|
previousEntries = entries
|
||||||
|
currentSeeds = seeds
|
||||||
|
rebuildEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func color(for identifier: String, isDark: Bool) -> Color? {
|
||||||
|
guard let entry = entries[identifier] else { return nil }
|
||||||
|
let saturation = isDark ? config.saturationDark : config.saturationLight
|
||||||
|
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
|
||||||
|
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
|
||||||
|
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
|
||||||
|
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func reset() {
|
||||||
|
currentSeeds.removeAll()
|
||||||
|
entries.removeAll()
|
||||||
|
previousEntries.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func rebuildEntries() {
|
||||||
|
guard !currentSeeds.isEmpty else {
|
||||||
|
entries.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let slotCount = max(8, config.slotCount)
|
||||||
|
var slots: [Double] = []
|
||||||
|
for idx in 0..<slotCount {
|
||||||
|
let hue = Double(idx) / Double(slotCount)
|
||||||
|
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slots.append(hue)
|
||||||
|
}
|
||||||
|
if slots.isEmpty {
|
||||||
|
for idx in 0..<slotCount {
|
||||||
|
slots.append(Double(idx) / Double(slotCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func circularDistance(_ a: Double, _ b: Double) -> Double {
|
||||||
|
let diff = abs(a - b)
|
||||||
|
return diff > 0.5 ? 1.0 - diff : diff
|
||||||
|
}
|
||||||
|
|
||||||
|
let peerIDs = currentSeeds.keys.sorted()
|
||||||
|
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
|
||||||
|
let seed = currentSeeds[id] ?? id
|
||||||
|
let hash = seed.djb2()
|
||||||
|
let index = Int(hash % UInt64(slots.count))
|
||||||
|
return (id, index)
|
||||||
|
})
|
||||||
|
|
||||||
|
var mapping: [String: Entry] = [:]
|
||||||
|
var usedSlots = Set<Int>()
|
||||||
|
var usedHues: [Double] = []
|
||||||
|
|
||||||
|
let prior = entries.isEmpty ? previousEntries : entries
|
||||||
|
for (id, entry) in prior {
|
||||||
|
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
|
||||||
|
let hue = slots[entry.slot]
|
||||||
|
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
|
||||||
|
usedSlots.insert(entry.slot)
|
||||||
|
usedHues.append(hue)
|
||||||
|
}
|
||||||
|
|
||||||
|
let unassigned = peerIDs.filter { mapping[$0] == nil }
|
||||||
|
for id in unassigned {
|
||||||
|
let preferred = preferredIndex[id] ?? 0
|
||||||
|
if !usedSlots.contains(preferred), preferred < slots.count {
|
||||||
|
let hue = slots[preferred]
|
||||||
|
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
|
||||||
|
usedSlots.insert(preferred)
|
||||||
|
usedHues.append(hue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestSlot: Int?
|
||||||
|
var bestScore = -Double.infinity
|
||||||
|
for slot in 0..<slots.count where !usedSlots.contains(slot) {
|
||||||
|
let hue = slots[slot]
|
||||||
|
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
|
||||||
|
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||||
|
let score = minDistance + config.preferredBiasWeight * bias
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestSlot = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let slot = bestSlot {
|
||||||
|
let hue = slots[slot]
|
||||||
|
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
|
||||||
|
usedSlots.insert(slot)
|
||||||
|
usedHues.append(hue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = peerIDs.filter { mapping[$0] == nil }
|
||||||
|
if !remaining.isEmpty {
|
||||||
|
for (index, id) in remaining.enumerated() {
|
||||||
|
let preferred = preferredIndex[id] ?? 0
|
||||||
|
let slot = (preferred + index * config.goldenStep) % slots.count
|
||||||
|
let hue = slots[slot]
|
||||||
|
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MinimalDistancePalette.Config {
|
||||||
|
static let mesh = MinimalDistancePalette.Config(
|
||||||
|
slotCount: TransportConfig.uiPeerPaletteSlots,
|
||||||
|
avoidCenterHue: 30.0 / 360.0,
|
||||||
|
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
||||||
|
saturationLight: 0.70,
|
||||||
|
saturationDark: 0.80,
|
||||||
|
baseBrightnessLight: 0.45,
|
||||||
|
baseBrightnessDark: 0.75,
|
||||||
|
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
||||||
|
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
||||||
|
)
|
||||||
|
|
||||||
|
static let nostr = MinimalDistancePalette.Config(
|
||||||
|
slotCount: TransportConfig.uiPeerPaletteSlots,
|
||||||
|
avoidCenterHue: 30.0 / 360.0,
|
||||||
|
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
|
||||||
|
saturationLight: 0.70,
|
||||||
|
saturationDark: 0.80,
|
||||||
|
baseBrightnessLight: 0.45,
|
||||||
|
baseBrightnessDark: 0.75,
|
||||||
|
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
|
||||||
|
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//
|
||||||
|
// PublicMessagePipeline.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol PublicMessagePipelineDelegate: AnyObject {
|
||||||
|
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
|
||||||
|
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
|
||||||
|
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
|
||||||
|
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PublicMessagePipeline {
|
||||||
|
weak var delegate: PublicMessagePipelineDelegate?
|
||||||
|
|
||||||
|
private var buffer: [BitchatMessage] = []
|
||||||
|
private var timer: Timer?
|
||||||
|
private let baseFlushInterval: TimeInterval
|
||||||
|
private var dynamicFlushInterval: TimeInterval
|
||||||
|
private var recentBatchSizes: [Int] = []
|
||||||
|
private let maxRecentBatchSamples: Int
|
||||||
|
private let dedupWindow: TimeInterval
|
||||||
|
private var activeChannel: ChannelID = .mesh
|
||||||
|
|
||||||
|
init(
|
||||||
|
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
|
||||||
|
maxRecentBatchSamples: Int = 10,
|
||||||
|
dedupWindow: TimeInterval = 1.0
|
||||||
|
) {
|
||||||
|
self.baseFlushInterval = baseFlushInterval
|
||||||
|
self.dynamicFlushInterval = baseFlushInterval
|
||||||
|
self.maxRecentBatchSamples = maxRecentBatchSamples
|
||||||
|
self.dedupWindow = dedupWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateActiveChannel(_ channel: ChannelID) {
|
||||||
|
activeChannel = channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(_ message: BitchatMessage) {
|
||||||
|
buffer.append(message)
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushIfNeeded() {
|
||||||
|
flushBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension PublicMessagePipeline {
|
||||||
|
func scheduleFlush() {
|
||||||
|
guard timer == nil else { return }
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.flushBuffer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushBuffer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
guard !buffer.isEmpty else { return }
|
||||||
|
guard let delegate = delegate else {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.pipelineSetBatchingState(self, isBatching: true)
|
||||||
|
|
||||||
|
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
|
||||||
|
var pending: [(message: BitchatMessage, contentKey: String)] = []
|
||||||
|
var batchContentLatest: [String: Date] = [:]
|
||||||
|
|
||||||
|
for message in buffer {
|
||||||
|
if existingIDs.contains(message.id) { continue }
|
||||||
|
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
|
||||||
|
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
|
||||||
|
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let ts = batchContentLatest[contentKey],
|
||||||
|
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingIDs.insert(message.id)
|
||||||
|
pending.append((message, contentKey))
|
||||||
|
batchContentLatest[contentKey] = message.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
guard !pending.isEmpty else {
|
||||||
|
delegate.pipelineSetBatchingState(self, isBatching: false)
|
||||||
|
if !buffer.isEmpty { scheduleFlush() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.sort { $0.message.timestamp < $1.message.timestamp }
|
||||||
|
|
||||||
|
var messages = delegate.pipelineCurrentMessages(self)
|
||||||
|
let threshold = lateInsertThreshold(for: activeChannel)
|
||||||
|
let lastTimestamp = messages.last?.timestamp ?? .distantPast
|
||||||
|
|
||||||
|
for item in pending {
|
||||||
|
let message = item.message
|
||||||
|
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
|
||||||
|
let index = insertionIndex(for: message.timestamp, in: messages)
|
||||||
|
if index >= messages.count {
|
||||||
|
messages.append(message)
|
||||||
|
} else {
|
||||||
|
messages.insert(message, at: index)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messages.append(message)
|
||||||
|
}
|
||||||
|
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.pipeline(self, setMessages: messages)
|
||||||
|
delegate.pipelineTrimMessages(self)
|
||||||
|
|
||||||
|
updateFlushInterval(withBatchSize: pending.count)
|
||||||
|
|
||||||
|
for item in pending {
|
||||||
|
delegate.pipelinePrewarmMessage(self, message: item.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.pipelineSetBatchingState(self, isBatching: false)
|
||||||
|
|
||||||
|
if !buffer.isEmpty {
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFlushInterval(withBatchSize size: Int) {
|
||||||
|
recentBatchSizes.append(size)
|
||||||
|
if recentBatchSizes.count > maxRecentBatchSamples {
|
||||||
|
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
|
||||||
|
}
|
||||||
|
let avg = recentBatchSizes.isEmpty
|
||||||
|
? 0.0
|
||||||
|
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
||||||
|
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
return TransportConfig.uiLateInsertThreshold
|
||||||
|
case .location:
|
||||||
|
return TransportConfig.uiLateInsertThresholdGeo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//
|
||||||
|
// PublicTimelineStore.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PublicTimelineStore {
|
||||||
|
private var meshTimeline: [BitchatMessage] = []
|
||||||
|
private var geohashTimelines: [String: [BitchatMessage]] = [:]
|
||||||
|
private var pendingGeohashSystemMessages: [String] = []
|
||||||
|
|
||||||
|
private let meshCap: Int
|
||||||
|
private let geohashCap: Int
|
||||||
|
|
||||||
|
init(meshCap: Int, geohashCap: Int) {
|
||||||
|
self.meshCap = meshCap
|
||||||
|
self.geohashCap = geohashCap
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
|
||||||
|
meshTimeline.append(message)
|
||||||
|
trimMeshTimelineIfNeeded()
|
||||||
|
case .location(let channel):
|
||||||
|
append(message, toGeohash: channel.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
|
||||||
|
var timeline = geohashTimelines[geohash] ?? []
|
||||||
|
guard !timeline.contains(where: { $0.id == message.id }) else { return }
|
||||||
|
timeline.append(message)
|
||||||
|
trimGeohashTimelineIfNeeded(&timeline)
|
||||||
|
geohashTimelines[geohash] = timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append message if absent, returning true when stored.
|
||||||
|
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||||
|
var timeline = geohashTimelines[geohash] ?? []
|
||||||
|
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
|
||||||
|
timeline.append(message)
|
||||||
|
trimGeohashTimelineIfNeeded(&timeline)
|
||||||
|
geohashTimelines[geohash] = timeline
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
return meshTimeline
|
||||||
|
case .location(let channel):
|
||||||
|
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
|
||||||
|
geohashTimelines[channel.geohash] = cleaned
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func clear(channel: ChannelID) {
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
meshTimeline.removeAll()
|
||||||
|
case .location(let channel):
|
||||||
|
geohashTimelines[channel.geohash] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func removeMessage(withID id: String) -> BitchatMessage? {
|
||||||
|
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
||||||
|
return meshTimeline.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in Array(geohashTimelines.keys) {
|
||||||
|
var timeline = geohashTimelines[key] ?? []
|
||||||
|
if let index = timeline.firstIndex(where: { $0.id == id }) {
|
||||||
|
let removed = timeline.remove(at: index)
|
||||||
|
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
||||||
|
var timeline = geohashTimelines[geohash] ?? []
|
||||||
|
timeline.removeAll(where: predicate)
|
||||||
|
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
|
||||||
|
var timeline = geohashTimelines[geohash] ?? []
|
||||||
|
transform(&timeline)
|
||||||
|
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func queueGeohashSystemMessage(_ content: String) {
|
||||||
|
pendingGeohashSystemMessages.append(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func drainPendingGeohashSystemMessages() -> [String] {
|
||||||
|
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
|
||||||
|
return pendingGeohashSystemMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
func geohashKeys() -> [String] {
|
||||||
|
Array(geohashTimelines.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func trimMeshTimelineIfNeeded() {
|
||||||
|
guard meshTimeline.count > meshCap else { return }
|
||||||
|
meshTimeline = Array(meshTimeline.suffix(meshCap))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
|
||||||
|
guard timeline.count > geohashCap else { return }
|
||||||
|
timeline = Array(timeline.suffix(geohashCap))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,41 +18,77 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let appName = "bitchat"
|
static let appName: LocalizedStringKey = "app_info.app_name"
|
||||||
static let tagline = "sidegroupchat"
|
static let tagline: LocalizedStringKey = "app_info.tagline"
|
||||||
|
|
||||||
enum Features {
|
enum Features {
|
||||||
static let title = "FEATURES"
|
static let title: LocalizedStringKey = "app_info.features.title"
|
||||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
static let offlineComm = AppInfoFeatureInfo(
|
||||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
icon: "wifi.slash",
|
||||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
title: "app_info.features.offline.title",
|
||||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
description: "app_info.features.offline.description"
|
||||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
)
|
||||||
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays")
|
static let encryption = AppInfoFeatureInfo(
|
||||||
|
icon: "lock.shield",
|
||||||
|
title: "app_info.features.encryption.title",
|
||||||
|
description: "app_info.features.encryption.description"
|
||||||
|
)
|
||||||
|
static let extendedRange = AppInfoFeatureInfo(
|
||||||
|
icon: "antenna.radiowaves.left.and.right",
|
||||||
|
title: "app_info.features.extended_range.title",
|
||||||
|
description: "app_info.features.extended_range.description"
|
||||||
|
)
|
||||||
|
static let mentions = AppInfoFeatureInfo(
|
||||||
|
icon: "at",
|
||||||
|
title: "app_info.features.mentions.title",
|
||||||
|
description: "app_info.features.mentions.description"
|
||||||
|
)
|
||||||
|
static let favorites = AppInfoFeatureInfo(
|
||||||
|
icon: "star.fill",
|
||||||
|
title: "app_info.features.favorites.title",
|
||||||
|
description: "app_info.features.favorites.description"
|
||||||
|
)
|
||||||
|
static let geohash = AppInfoFeatureInfo(
|
||||||
|
icon: "number",
|
||||||
|
title: "app_info.features.geohash.title",
|
||||||
|
description: "app_info.features.geohash.description"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Privacy {
|
enum Privacy {
|
||||||
static let title = "PRIVACY"
|
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
static let noTracking = AppInfoFeatureInfo(
|
||||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
icon: "eye.slash",
|
||||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
title: "app_info.privacy.no_tracking.title",
|
||||||
|
description: "app_info.privacy.no_tracking.description"
|
||||||
|
)
|
||||||
|
static let ephemeral = AppInfoFeatureInfo(
|
||||||
|
icon: "shuffle",
|
||||||
|
title: "app_info.privacy.ephemeral.title",
|
||||||
|
description: "app_info.privacy.ephemeral.description"
|
||||||
|
)
|
||||||
|
static let panic = AppInfoFeatureInfo(
|
||||||
|
icon: "hand.raised.fill",
|
||||||
|
title: "app_info.privacy.panic.title",
|
||||||
|
description: "app_info.privacy.panic.description"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HowToUse {
|
enum HowToUse {
|
||||||
static let title = "HOW TO USE"
|
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||||
static let instructions = [
|
static let instructions: [LocalizedStringKey] = [
|
||||||
"• set your nickname by tapping it",
|
"app_info.how_to_use.set_nickname",
|
||||||
"• tap #mesh to change channels",
|
"app_info.how_to_use.change_channels",
|
||||||
"• tap people icon for sidebar",
|
"app_info.how_to_use.open_sidebar",
|
||||||
"• tap a peer's name to start a DM",
|
"app_info.how_to_use.start_dm",
|
||||||
"• triple-tap chat to clear",
|
"app_info.how_to_use.clear_chat",
|
||||||
"• type / for commands"
|
"app_info.how_to_use.commands"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Warning {
|
enum Warning {
|
||||||
static let title = "WARNING"
|
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||||
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears."
|
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +98,7 @@ struct AppInfoView: View {
|
|||||||
// Custom header for macOS
|
// Custom header for macOS
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("DONE") {
|
Button("app_info.done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -93,7 +129,7 @@ struct AppInfoView: View {
|
|||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel("app_info.close")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +157,7 @@ struct AppInfoView: View {
|
|||||||
SectionHeader(Strings.HowToUse.title)
|
SectionHeader(Strings.HowToUse.title)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
|
||||||
Text(instruction)
|
Text(instruction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,49 +168,31 @@ struct AppInfoView: View {
|
|||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Features.title)
|
SectionHeader(Strings.Features.title)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
FeatureRow(info: Strings.Features.offlineComm)
|
||||||
title: Strings.Features.offlineComm.1,
|
|
||||||
description: Strings.Features.offlineComm.2)
|
FeatureRow(info: Strings.Features.encryption)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.encryption.0,
|
FeatureRow(info: Strings.Features.extendedRange)
|
||||||
title: Strings.Features.encryption.1,
|
|
||||||
description: Strings.Features.encryption.2)
|
FeatureRow(info: Strings.Features.favorites)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
FeatureRow(info: Strings.Features.geohash)
|
||||||
title: Strings.Features.extendedRange.1,
|
|
||||||
description: Strings.Features.extendedRange.2)
|
FeatureRow(info: Strings.Features.mentions)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.favorites.0,
|
|
||||||
title: Strings.Features.favorites.1,
|
|
||||||
description: Strings.Features.favorites.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.geohash.0,
|
|
||||||
title: Strings.Features.geohash.1,
|
|
||||||
description: Strings.Features.geohash.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.mentions.0,
|
|
||||||
title: Strings.Features.mentions.1,
|
|
||||||
description: Strings.Features.mentions.2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Privacy
|
// Privacy
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Privacy.title)
|
SectionHeader(Strings.Privacy.title)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
FeatureRow(info: Strings.Privacy.noTracking)
|
||||||
title: Strings.Privacy.noTracking.1,
|
|
||||||
description: Strings.Privacy.noTracking.2)
|
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
title: Strings.Privacy.ephemeral.1,
|
|
||||||
description: Strings.Privacy.ephemeral.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.panic.0,
|
|
||||||
title: Strings.Privacy.panic.1,
|
|
||||||
description: Strings.Privacy.panic.2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning
|
// Warning
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
SectionHeader(Strings.Warning.title)
|
SectionHeader(Strings.Warning.title)
|
||||||
@@ -197,15 +215,21 @@ struct AppInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppInfoFeatureInfo {
|
||||||
|
let icon: String
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
let description: LocalizedStringKey
|
||||||
|
}
|
||||||
|
|
||||||
struct SectionHeader: View {
|
struct SectionHeader: View {
|
||||||
let title: String
|
let title: LocalizedStringKey
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ title: String) {
|
init(_ title: LocalizedStringKey) {
|
||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +242,7 @@ struct SectionHeader: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FeatureRow: View {
|
struct FeatureRow: View {
|
||||||
let icon: String
|
let info: AppInfoFeatureInfo
|
||||||
let title: String
|
|
||||||
let description: String
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
@@ -233,17 +255,17 @@ struct FeatureRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: info.icon)
|
||||||
.font(.bitchatSystem(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 30)
|
.frame(width: 30)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(title)
|
Text(info.title)
|
||||||
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(description)
|
Text(info.description)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// CommandSuggestionsView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Created by Islam on 29/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CommandSuggestionsView: View {
|
||||||
|
@EnvironmentObject private var viewModel: ChatViewModel
|
||||||
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
|
|
||||||
|
@Binding var messageText: String
|
||||||
|
|
||||||
|
let textColor: Color
|
||||||
|
let backgroundColor: Color
|
||||||
|
let secondaryTextColor: Color
|
||||||
|
|
||||||
|
private var filteredCommands: [CommandInfo] {
|
||||||
|
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||||
|
let isGeoPublic = locationManager.selectedChannel.isLocation
|
||||||
|
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||||
|
command.alias.starts(with: messageText.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(filteredCommands) { command in
|
||||||
|
Button {
|
||||||
|
messageText = command.alias + " "
|
||||||
|
} label: {
|
||||||
|
buttonRow(for: command)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buttonRow(for command: CommandInfo) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(command.alias)
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
if let placeholder = command.placeholder {
|
||||||
|
Text(placeholder)
|
||||||
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(command.description)
|
||||||
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 17, macOS 14, *)
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var messageText: String = "/"
|
||||||
|
let keychain = KeychainManager()
|
||||||
|
let viewModel = ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: NostrIdentityBridge(),
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
|
||||||
|
CommandSuggestionsView(
|
||||||
|
messageText: $messageText,
|
||||||
|
textColor: .green,
|
||||||
|
backgroundColor: .primary,
|
||||||
|
secondaryTextColor: .secondary
|
||||||
|
)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// DeliveryStatusView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DeliveryStatusView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let status: DeliveryStatus
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private var textColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secondaryTextColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static func delivered(to nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func read(by nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func failed(_ reason: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||||
|
locale: .current,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||||
|
locale: .current,
|
||||||
|
reached,
|
||||||
|
total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch status {
|
||||||
|
case .sending:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
|
case .sent:
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
|
case .delivered(let nickname, _):
|
||||||
|
HStack(spacing: -2) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
}
|
||||||
|
.foregroundColor(textColor.opacity(0.8))
|
||||||
|
.help(Strings.delivered(to: nickname))
|
||||||
|
|
||||||
|
case .read(let nickname, _):
|
||||||
|
HStack(spacing: -2) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||||
|
.help(Strings.read(by: nickname))
|
||||||
|
|
||||||
|
case .failed(let reason):
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(Color.red.opacity(0.8))
|
||||||
|
.help(Strings.failed(reason))
|
||||||
|
|
||||||
|
case .partiallyDelivered(let reached, let total):
|
||||||
|
HStack(spacing: 1) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
Text(verbatim: "\(reached)/\(total)")
|
||||||
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
|
}
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
.help(Strings.deliveredToMembers(reached, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let statuses: [DeliveryStatus] = [
|
||||||
|
.sending,
|
||||||
|
.sent,
|
||||||
|
.delivered(to: "John Doe", at: Date()),
|
||||||
|
.read(by: "Jane Doe", at: Date()),
|
||||||
|
.failed(reason: "Offline"),
|
||||||
|
.partiallyDelivered(reached: 2, total: 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(statuses, id: \.self) { status in
|
||||||
|
HStack {
|
||||||
|
Text(status.displayText)
|
||||||
|
Spacer()
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(statuses, id: \.self) { status in
|
||||||
|
HStack {
|
||||||
|
Text(status.displayText)
|
||||||
|
Spacer()
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user