mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:45:20 +00:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e868e3bbfb | ||
|
|
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 | ||
|
|
1fd2da18f5 | ||
|
|
c49a1b264e | ||
|
|
10c0391eaf | ||
|
|
3a94b57341 | ||
|
|
f8f780d2d6 | ||
|
|
c837afb818 | ||
|
|
47ef82f01a | ||
|
|
d1e5ce21a7 | ||
|
|
f2c1bb2131 | ||
|
|
221819b591 | ||
|
|
4a21ab0531 | ||
|
|
50ae8da5f9 | ||
|
|
54bb812469 | ||
|
|
482fca81ef | ||
|
|
b2e7d2d26e | ||
|
|
6a2832d22b | ||
|
|
56e7324069 | ||
|
|
1733dda6cd | ||
|
|
00ff5fd31c | ||
|
|
f684c452b2 | ||
|
|
9cbdb0a764 | ||
|
|
d1682db79b | ||
|
|
6a6504c6f2 | ||
|
|
ea8d51a36b | ||
|
|
347ce5ece4 | ||
|
|
7c4bde59b9 | ||
|
|
2ac01db9c4 | ||
|
|
42cdc4c123 | ||
|
|
fb94e799a5 | ||
|
|
04671caeb8 | ||
|
|
a485335649 | ||
|
|
de2b5ed142 | ||
|
|
57cc9028cb | ||
|
|
b0a50c663f | ||
|
|
0a98844c1a | ||
|
|
efb9a5070d | ||
|
|
2870acdcc7 | ||
|
|
920dc31795 | ||
|
|
bb3d99bdca | ||
|
|
b01cac4649 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258659000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 14166264
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258662000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 18068
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754191141000000000
|
||||||
|
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 1224
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258669000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 699544
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192470000000000
|
||||||
|
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 364219
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258664000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 83568
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192532000000000
|
||||||
|
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 24507
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Prevent Github Languages stats skewing:
|
||||||
|
|
||||||
|
# Binaries and assets
|
||||||
|
**/*.xcframework/** linguist-vendored
|
||||||
|
**/*.xcassets/** linguist-vendored
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
**/*.pbxproj linguist-generated
|
||||||
|
**/*.storyboard linguist-generated
|
||||||
|
Package.resolved linguist-generated
|
||||||
|
|
||||||
|
# Downloaded CSVs
|
||||||
|
relays/online_relays_gps.csv linguist-vendored
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
**/*.md linguist-documentation
|
||||||
|
|
||||||
|
# Configs
|
||||||
|
Configs/*.xcconfig linguist-documentation
|
||||||
|
**/*.plist linguist-documentation
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Build & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Swift Tests
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Swift
|
||||||
|
uses: swift-actions/setup-swift@v2
|
||||||
|
|
||||||
|
- name: Build the package
|
||||||
|
run: swift build
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
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__/
|
||||||
@@ -75,3 +73,6 @@ TestResult.xcresult/
|
|||||||
*.xcresult/
|
*.xcresult/
|
||||||
build.log
|
build.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local configs
|
||||||
|
Local.xcconfig
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#include "Release.xcconfig"
|
||||||
|
|
||||||
|
// Optional include of local configs
|
||||||
|
#include? "Local.xcconfig"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
|
||||||
|
DEVELOPMENT_TEAM = ABC123
|
||||||
|
|
||||||
|
// Unique bundle id to be able to register and run locally
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
MARKETING_VERSION = 1.4.4
|
||||||
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0
|
||||||
|
SWIFT_VERSION = 5.0
|
||||||
|
|
||||||
|
DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||||
|
CODE_SIGN_STYLE = Automatic
|
||||||
|
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
Place Tor.xcframework here
|
|
||||||
|
|
||||||
Instructions
|
|
||||||
- Obtain a prebuilt Tor Apple xcframework (iCepa/Onion Browser lineage) or build your own minimal client-only Tor.
|
|
||||||
- Rename it (if needed) to `Tor.xcframework` and drop it in this `Frameworks/` directory.
|
|
||||||
- Regenerate the Xcode project if you use XcodeGen (`project.yml` already references `Frameworks/Tor.xcframework`).
|
|
||||||
- Build the app; `TorManager` will automatically bootstrap Tor and route all networking through it.
|
|
||||||
|
|
||||||
Notes
|
|
||||||
- For iOS, the framework will be embedded and code-signed automatically.
|
|
||||||
- For macOS, it will be linked and embedded as well (you may prefer a system tor for smaller bundles).
|
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
|
|
||||||
@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 "❌ Xcode 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)
|
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
@@ -22,8 +21,6 @@ check:
|
|||||||
# Backup original files
|
# Backup original files
|
||||||
backup:
|
backup:
|
||||||
@echo "Backing up original project configuration..."
|
@echo "Backing up original project configuration..."
|
||||||
@cp project.yml project.yml.backup 2>/dev/null || true
|
|
||||||
@# Backup other files that get modified by xcodegen
|
|
||||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||||
|
|
||||||
@@ -44,15 +41,10 @@ patch-for-macos: backup
|
|||||||
@# Move iOS-specific files out of the way temporarily
|
@# Move iOS-specific files out of the way temporarily
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||||
|
|
||||||
# Generate Xcode project with patches
|
|
||||||
generate: patch-for-macos
|
|
||||||
@echo "Generating Xcode project..."
|
|
||||||
@xcodegen generate
|
|
||||||
|
|
||||||
# Build the macOS app
|
# Build the macOS app
|
||||||
build: check generate
|
build: #check generate
|
||||||
@echo "Building BitChat for macOS..."
|
@echo "Building BitChat for macOS..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
|
|
||||||
# Run the macOS app
|
# Run the macOS app
|
||||||
run: build
|
run: build
|
||||||
@@ -75,9 +67,7 @@ clean: restore
|
|||||||
# Quick run without cleaning (for development)
|
# Quick run without cleaning (for development)
|
||||||
dev-run: check
|
dev-run: check
|
||||||
@echo "Quick development build..."
|
@echo "Quick development build..."
|
||||||
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
@xcodegen generate
|
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||||
|
|
||||||
# Show app info
|
# Show app info
|
||||||
@@ -106,11 +96,9 @@ nuke:
|
|||||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||||
@rm -f project.yml.backup 2>/dev/null || true
|
|
||||||
@rm -f project-macos.yml 2>/dev/null || true
|
|
||||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||||
@# Restore iOS-specific files if they were moved
|
@# Restore iOS-specific files if they were moved
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||||
@echo "✅ Nuclear clean complete"
|
@echo "✅ Nuclear clean complete"
|
||||||
|
|||||||
+22
-2
@@ -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,13 +16,17 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
|
.package(path: "localPackages/Tor"),
|
||||||
|
.package(path: "localPackages/BitLogger"),
|
||||||
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1")
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
|
.product(name: "Tor", package: "Tor")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -30,7 +35,22 @@ let package = Package(
|
|||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard"
|
"LaunchScreen.storyboard"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localizable.xcstrings")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "bitchatTests",
|
||||||
|
dependencies: ["bitchat"],
|
||||||
|
path: "bitchatTests",
|
||||||
|
exclude: [
|
||||||
|
"Info.plist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localization")
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
@@ -94,45 +94,32 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Option 1: Using XcodeGen (Recommended)
|
### Option 1: Using Xcode
|
||||||
|
|
||||||
1. Install XcodeGen if you haven't already:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install xcodegen
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Generate the Xcode project:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
xcodegen generate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open the generated project:
|
|
||||||
```bash
|
|
||||||
open bitchat.xcodeproj
|
open bitchat.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Using Swift Package Manager
|
To run on a device there're a few steps to prepare the code:
|
||||||
|
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||||
|
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||||
|
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||||
|
- Entitlements need to be updated manually (TODO: Automate):
|
||||||
|
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||||
|
|
||||||
1. Open the project in Xcode:
|
### Option 2: Using `just`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
brew install just
|
||||||
open Package.swift
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Select your target device and run
|
|
||||||
|
|
||||||
### Option 3: Manual Xcode Project
|
|
||||||
|
|
||||||
1. Open Xcode and create a new iOS/macOS App
|
|
||||||
2. Copy all Swift files from the `bitchat` directory into your project
|
|
||||||
3. Update Info.plist with Bluetooth permissions
|
|
||||||
4. Set deployment target to iOS 16.0 / macOS 13.0
|
|
||||||
|
|
||||||
### Option 4: just
|
|
||||||
|
|
||||||
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
+255
-814
File diff suppressed because it is too large
Load Diff
@@ -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 = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
|
value = ""
|
||||||
|
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>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "image-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 2.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
+36
-19
@@ -6,12 +6,16 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
@StateObject private var chatViewModel = ChatViewModel()
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
|
@StateObject private var chatViewModel: ChatViewModel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@@ -23,6 +27,14 @@ struct BitchatApp: App {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
let keychain = KeychainManager()
|
||||||
|
_chatViewModel = StateObject(
|
||||||
|
wrappedValue: ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
// Warm up georelay directory and refresh if stale (once/day)
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
@@ -37,17 +49,18 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
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
|
||||||
|
let nickname = chatViewModel.nickname
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#endif
|
#endif
|
||||||
// Spin up Tor early; all internet will gate on Tor 100%
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
TorManager.shared.startIfNeeded()
|
NetworkActivationService.shared.start()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -59,7 +72,7 @@ struct BitchatApp: App {
|
|||||||
switch newPhase {
|
switch newPhase {
|
||||||
case .background:
|
case .background:
|
||||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||||
// Optionally nudge Tor to dormant to save power
|
// Always send Tor to dormant on background for a clean restart later.
|
||||||
TorManager.shared.setAppForeground(false)
|
TorManager.shared.setAppForeground(false)
|
||||||
TorManager.shared.goDormantOnBackground()
|
TorManager.shared.goDormantOnBackground()
|
||||||
// Stop geohash sampling while backgrounded
|
// Stop geohash sampling while backgrounded
|
||||||
@@ -76,11 +89,14 @@ struct BitchatApp: App {
|
|||||||
// On initial cold launch, Tor was just started in onAppear.
|
// On initial cold launch, Tor was just started in onAppear.
|
||||||
// Skip the deterministic restart the first time we become active.
|
// Skip the deterministic restart the first time we become active.
|
||||||
if didHandleInitialActive && didEnterBackground {
|
if didHandleInitialActive && didEnterBackground {
|
||||||
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
didHandleInitialActive = true
|
didHandleInitialActive = true
|
||||||
}
|
}
|
||||||
didEnterBackground = false
|
didEnterBackground = false
|
||||||
|
if TorManager.shared.isAutoStartAllowed() {
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -90,6 +106,7 @@ struct BitchatApp: App {
|
|||||||
NostrRelayManager.shared.resetAllConnections()
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break
|
break
|
||||||
@@ -122,7 +139,7 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
private func checkForSharedContent() {
|
private func checkForSharedContent() {
|
||||||
// Check app group for shared content from extension
|
// Check app group for shared content from extension
|
||||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,30 +238,30 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor [weak self] in
|
||||||
if identifier.hasPrefix("private-") {
|
guard let self = self else {
|
||||||
// Get peer ID from userInfo
|
completionHandler([.banner, .sound])
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
|
||||||
// Don't show notification if the private chat is already open
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
// Check if this is a private message notification and chat already open
|
||||||
|
if identifier.hasPrefix("private-"),
|
||||||
|
let peerID = userInfo["peerID"] as? String,
|
||||||
|
self.chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
let deep = userInfo["deeplink"] as? String,
|
let deep = userInfo["deeplink"] as? String,
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
let gh = deep.components(separatedBy: "/").last,
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
|
ch.geohash == gh {
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
completionHandler([.banner, .sound])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -158,23 +158,6 @@ struct IdentityCache: Codable {
|
|||||||
var version: Int = 1
|
var version: Int = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Identity Resolution
|
|
||||||
|
|
||||||
enum IdentityHint {
|
|
||||||
case unknown
|
|
||||||
case likelyKnown(fingerprint: String)
|
|
||||||
case ambiguous(candidates: Set<String>)
|
|
||||||
case verified(fingerprint: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pending Actions
|
|
||||||
|
|
||||||
struct PendingActions {
|
|
||||||
var toggleFavorite: Bool?
|
|
||||||
var setTrustLevel: TrustLevel?
|
|
||||||
var setPetname: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
||||||
// MARK: - Migration Support
|
// MARK: - Migration Support
|
||||||
|
|||||||
@@ -90,27 +90,63 @@
|
|||||||
/// - Advanced conflict resolution
|
/// - Advanced conflict resolution
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
protocol SecureIdentityStateManagerProtocol {
|
||||||
|
// MARK: Secure Loading/Saving
|
||||||
|
func forceSave()
|
||||||
|
|
||||||
|
// MARK: Social Identity Management
|
||||||
|
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
|
||||||
|
|
||||||
|
// MARK: Cryptographic Identities
|
||||||
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||||
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||||
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
|
// MARK: Favorites Management
|
||||||
|
func getFavorites() -> Set<String>
|
||||||
|
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
||||||
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
|
// MARK: Blocked Users Management
|
||||||
|
func isBlocked(fingerprint: String) -> Bool
|
||||||
|
func setBlocked(_ fingerprint: String, isBlocked: Bool)
|
||||||
|
|
||||||
|
// MARK: Geohash (Nostr) Blocking
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||||
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
|
||||||
|
func getBlockedNostrPubkeys() -> Set<String>
|
||||||
|
|
||||||
|
// MARK: Ephemeral Session Management
|
||||||
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||||
|
|
||||||
|
// MARK: Cleanup
|
||||||
|
func clearAllIdentityData()
|
||||||
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
|
|
||||||
|
// MARK: Verification
|
||||||
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
|
func isVerified(fingerprint: String) -> Bool
|
||||||
|
func getVerifiedFingerprints() -> Set<String>
|
||||||
|
}
|
||||||
|
|
||||||
/// Singleton manager for secure identity state persistence and retrieval.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
/// Provides thread-safe access to identity mappings with encryption at rest.
|
/// Provides thread-safe access to identity mappings with encryption at rest.
|
||||||
/// All identity data is stored encrypted in the device Keychain for security.
|
/// All identity data is stored encrypted in the device Keychain for security.
|
||||||
final class SecureIdentityStateManager {
|
final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||||
static let shared = SecureIdentityStateManager()
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
private let keychain = KeychainManager.shared
|
|
||||||
private let cacheKey = "bitchat.identityCache.v2"
|
private let cacheKey = "bitchat.identityCache.v2"
|
||||||
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()
|
||||||
|
|
||||||
// Pending actions before handshake
|
|
||||||
private var pendingActions: [String: PendingActions] = [:]
|
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -122,7 +158,9 @@ final class SecureIdentityStateManager {
|
|||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
private let encryptionKey: SymmetricKey
|
||||||
|
|
||||||
private init() {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
|
self.keychain = keychain
|
||||||
|
|
||||||
// Generate or retrieve encryption key from keychain
|
// Generate or retrieve encryption key from keychain
|
||||||
let loadedKey: SymmetricKey
|
let loadedKey: SymmetricKey
|
||||||
|
|
||||||
@@ -146,9 +184,13 @@ final class SecureIdentityStateManager {
|
|||||||
loadIdentityCache()
|
loadIdentityCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
forceSave()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Secure Loading/Saving
|
// MARK: - Secure Loading/Saving
|
||||||
|
|
||||||
func loadIdentityCache() {
|
private func loadIdentityCache() {
|
||||||
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
|
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
|
||||||
// No existing cache, start fresh
|
// No existing cache, start fresh
|
||||||
return
|
return
|
||||||
@@ -164,12 +206,7 @@ final class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
private func saveIdentityCache() {
|
||||||
// Force save any pending changes
|
|
||||||
forceSave()
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityCache() {
|
|
||||||
// Mark that we need to save
|
// Mark that we need to save
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
|
|
||||||
@@ -201,26 +238,8 @@ final class SecureIdentityStateManager {
|
|||||||
// Force immediate save (for app termination)
|
// Force immediate save (for app termination)
|
||||||
func forceSave() {
|
func forceSave() {
|
||||||
saveTimer?.invalidate()
|
saveTimer?.invalidate()
|
||||||
if pendingSave {
|
|
||||||
performSave()
|
performSave()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Identity Resolution
|
|
||||||
|
|
||||||
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
|
|
||||||
queue.sync {
|
|
||||||
// Check if we have candidates based on nickname
|
|
||||||
if let fingerprints = cache.nicknameIndex[claimedNickname] {
|
|
||||||
if fingerprints.count == 1 {
|
|
||||||
return .likelyKnown(fingerprint: fingerprints.first!)
|
|
||||||
} else {
|
|
||||||
return .ambiguous(candidates: fingerprints)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Social Identity Management
|
// MARK: - Social Identity Management
|
||||||
|
|
||||||
@@ -301,23 +320,12 @@ final class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve cryptographic identity by fingerprint
|
|
||||||
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
|
||||||
queue.sync { cryptographicIdentities[fingerprint] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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) }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
|
||||||
queue.sync {
|
|
||||||
return Array(cache.socialIdentities.values)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +455,7 @@ final class SecureIdentityStateManager {
|
|||||||
|
|
||||||
// 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,
|
||||||
@@ -457,7 +465,7 @@ final class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -469,53 +477,6 @@ final class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeState(peerID: String) -> HandshakeState? {
|
|
||||||
queue.sync {
|
|
||||||
return ephemeralSessions[peerID]?.handshakeState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pending Actions
|
|
||||||
|
|
||||||
func setPendingAction(peerID: String, action: PendingActions) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.pendingActions[peerID] = action
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyPendingActions(peerID: String, fingerprint: String) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
guard let actions = self.pendingActions[peerID] else { return }
|
|
||||||
|
|
||||||
// Get or create social identity
|
|
||||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: "Unknown",
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Apply pending actions
|
|
||||||
if let toggleFavorite = actions.toggleFavorite {
|
|
||||||
identity.isFavorite = toggleFavorite
|
|
||||||
}
|
|
||||||
if let trustLevel = actions.setTrustLevel {
|
|
||||||
identity.trustLevel = trustLevel
|
|
||||||
}
|
|
||||||
if let petname = actions.setPetname {
|
|
||||||
identity.localPetname = petname
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save updated identity
|
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
|
||||||
self.pendingActions.removeValue(forKey: peerID)
|
|
||||||
self.saveIdentityCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
func clearAllIdentityData() {
|
func clearAllIdentityData() {
|
||||||
@@ -525,7 +486,6 @@ final class SecureIdentityStateManager {
|
|||||||
self.cache = IdentityCache()
|
self.cache = IdentityCache()
|
||||||
self.ephemeralSessions.removeAll()
|
self.ephemeralSessions.removeAll()
|
||||||
self.cryptographicIdentities.removeAll()
|
self.cryptographicIdentities.removeAll()
|
||||||
self.pendingActions.removeAll()
|
|
||||||
|
|
||||||
// Delete from keychain
|
// Delete from keychain
|
||||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||||
@@ -533,10 +493,9 @@ final class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
self.pendingActions.removeValue(forKey: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
|||||||
|
//
|
||||||
|
// BitchatMessage.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a user-visible message in the BitChat system.
|
||||||
|
/// Handles both broadcast messages and private encrypted messages,
|
||||||
|
/// with support for mentions, replies, and delivery tracking.
|
||||||
|
/// - Note: This is the primary data model for chat messages
|
||||||
|
final class BitchatMessage: Codable {
|
||||||
|
let id: String
|
||||||
|
let sender: String
|
||||||
|
let content: String
|
||||||
|
let timestamp: Date
|
||||||
|
let isRelay: Bool
|
||||||
|
let originalSender: String?
|
||||||
|
let isPrivate: Bool
|
||||||
|
let recipientNickname: String?
|
||||||
|
let senderPeerID: PeerID?
|
||||||
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
|
// Cached formatted text (not included in Codable)
|
||||||
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codable implementation
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, sender, content, timestamp, isRelay, originalSender
|
||||||
|
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: PeerID? = nil,
|
||||||
|
mentions: [String]? = nil,
|
||||||
|
deliveryStatus: DeliveryStatus? = nil
|
||||||
|
) {
|
||||||
|
self.id = id ?? UUID().uuidString
|
||||||
|
self.sender = sender
|
||||||
|
self.content = content
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.isRelay = isRelay
|
||||||
|
self.originalSender = originalSender
|
||||||
|
self.isPrivate = isPrivate
|
||||||
|
self.recipientNickname = recipientNickname
|
||||||
|
self.senderPeerID = senderPeerID
|
||||||
|
self.mentions = mentions
|
||||||
|
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Equatable Conformance
|
||||||
|
|
||||||
|
extension BitchatMessage: Equatable {
|
||||||
|
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||||
|
return lhs.id == rhs.id &&
|
||||||
|
lhs.sender == rhs.sender &&
|
||||||
|
lhs.content == rhs.content &&
|
||||||
|
lhs.timestamp == rhs.timestamp &&
|
||||||
|
lhs.isRelay == rhs.isRelay &&
|
||||||
|
lhs.originalSender == rhs.originalSender &&
|
||||||
|
lhs.isPrivate == rhs.isPrivate &&
|
||||||
|
lhs.recipientNickname == rhs.recipientNickname &&
|
||||||
|
lhs.senderPeerID == rhs.senderPeerID &&
|
||||||
|
lhs.mentions == rhs.mentions &&
|
||||||
|
lhs.deliveryStatus == rhs.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary encoding
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
func toBinaryPayload() -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
// Message format:
|
||||||
|
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
||||||
|
// - Timestamp: 8 bytes (seconds since epoch)
|
||||||
|
// - ID length: 1 byte
|
||||||
|
// - ID: variable
|
||||||
|
// - Sender length: 1 byte
|
||||||
|
// - Sender: variable
|
||||||
|
// - Content length: 2 bytes
|
||||||
|
// - Content: variable
|
||||||
|
// Optional fields based on flags:
|
||||||
|
// - Original sender length + data
|
||||||
|
// - Recipient nickname length + data
|
||||||
|
// - Sender peer ID length + data
|
||||||
|
// - Mentions array
|
||||||
|
|
||||||
|
var flags: UInt8 = 0
|
||||||
|
if isRelay { flags |= 0x01 }
|
||||||
|
if isPrivate { flags |= 0x02 }
|
||||||
|
if originalSender != nil { flags |= 0x04 }
|
||||||
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
|
if senderPeerID != nil { flags |= 0x10 }
|
||||||
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
|
||||||
|
data.append(flags)
|
||||||
|
|
||||||
|
// Timestamp (in milliseconds)
|
||||||
|
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
||||||
|
// Encode as 8 bytes, big-endian
|
||||||
|
for i in (0..<8).reversed() {
|
||||||
|
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID
|
||||||
|
if let idData = id.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(idData.count, 255)))
|
||||||
|
data.append(idData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
if let senderData = sender.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(senderData.count, 255)))
|
||||||
|
data.append(senderData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if let contentData = content.data(using: .utf8) {
|
||||||
|
let length = UInt16(min(contentData.count, 65535))
|
||||||
|
// Encode length as 2 bytes, big-endian
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
data.append(contentData.prefix(Int(length)))
|
||||||
|
} else {
|
||||||
|
data.append(contentsOf: [0, 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(origData.count, 255)))
|
||||||
|
data.append(origData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(recipData.count, 255)))
|
||||||
|
data.append(recipData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
|
data.append(peerData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentions array
|
||||||
|
if let mentions = mentions {
|
||||||
|
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
|
||||||
|
for mention in mentions.prefix(255) {
|
||||||
|
if let mentionData = mention.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(mentionData.count, 255)))
|
||||||
|
data.append(mentionData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init?(_ data: Data) {
|
||||||
|
// Create an immutable copy to prevent threading issues
|
||||||
|
let dataCopy = Data(data)
|
||||||
|
|
||||||
|
|
||||||
|
guard dataCopy.count >= 13 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let flags = dataCopy[offset]; offset += 1
|
||||||
|
let isRelay = (flags & 0x01) != 0
|
||||||
|
let isPrivate = (flags & 0x02) != 0
|
||||||
|
let hasOriginalSender = (flags & 0x04) != 0
|
||||||
|
let hasRecipientNickname = (flags & 0x08) != 0
|
||||||
|
let hasSenderPeerID = (flags & 0x10) != 0
|
||||||
|
let hasMentions = (flags & 0x20) != 0
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
guard offset + 8 <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let timestampData = dataCopy[offset..<offset+8]
|
||||||
|
let timestampMillis = timestampData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
offset += 8
|
||||||
|
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
|
||||||
|
|
||||||
|
// ID
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let idLength = Int(dataCopy[offset]); offset += 1
|
||||||
|
guard offset + idLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
|
||||||
|
offset += idLength
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let senderLength = Int(dataCopy[offset]); offset += 1
|
||||||
|
guard offset + senderLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
|
||||||
|
offset += senderLength
|
||||||
|
|
||||||
|
// Content
|
||||||
|
guard offset + 2 <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let contentLengthData = dataCopy[offset..<offset+2]
|
||||||
|
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt16(byte)
|
||||||
|
})
|
||||||
|
offset += 2
|
||||||
|
guard offset + contentLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
|
||||||
|
offset += contentLength
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
var originalSender: String?
|
||||||
|
if hasOriginalSender && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipientNickname: String?
|
||||||
|
if hasRecipientNickname && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var senderPeerID: PeerID?
|
||||||
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentions array
|
||||||
|
var mentions: [String]?
|
||||||
|
if hasMentions && offset < dataCopy.count {
|
||||||
|
let mentionCount = Int(dataCopy[offset]); offset += 1
|
||||||
|
if mentionCount > 0 {
|
||||||
|
mentions = []
|
||||||
|
for _ in 0..<mentionCount {
|
||||||
|
if offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
||||||
|
mentions?.append(mention)
|
||||||
|
}
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: isRelay,
|
||||||
|
originalSender: originalSender,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
recipientNickname: recipientNickname,
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
mentions: mentions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
|
||||||
|
private static let timestampFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
Self.timestampFormatter.string(from: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == BitchatMessage {
|
||||||
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
|
func cleanedAndDeduped() -> [Element] {
|
||||||
|
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||||
|
guard arr.count > 1 else {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
var seen = Set<String>()
|
||||||
|
var dedup: [BitchatMessage] = []
|
||||||
|
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
|
if !seen.contains(m.id) {
|
||||||
|
dedup.append(m)
|
||||||
|
seen.insert(m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// BitchatPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The core packet structure for all BitChat protocol messages.
|
||||||
|
/// Encapsulates all data needed for routing through the mesh network,
|
||||||
|
/// including TTL for hop limiting and optional encryption.
|
||||||
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||||
|
struct BitchatPacket: Codable {
|
||||||
|
let version: UInt8
|
||||||
|
let type: UInt8
|
||||||
|
let senderID: Data
|
||||||
|
let recipientID: Data?
|
||||||
|
let timestamp: UInt64
|
||||||
|
let payload: Data
|
||||||
|
var signature: Data?
|
||||||
|
var ttl: UInt8
|
||||||
|
|
||||||
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
self.senderID = senderID
|
||||||
|
self.recipientID = recipientID
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = signature
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience initializer for new binary format
|
||||||
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
|
var senderData = Data()
|
||||||
|
var tempID = senderID.id
|
||||||
|
while tempID.count >= 2 {
|
||||||
|
let hexByte = String(tempID.prefix(2))
|
||||||
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
senderData.append(byte)
|
||||||
|
}
|
||||||
|
tempID = String(tempID.dropFirst(2))
|
||||||
|
}
|
||||||
|
self.senderID = senderData
|
||||||
|
self.recipientID = nil
|
||||||
|
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = nil
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: Data? {
|
||||||
|
BinaryProtocol.encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBinaryData(padding: Bool = true) -> Data? {
|
||||||
|
BinaryProtocol.encode(self, padding: padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible helper (defaults to padded encoding)
|
||||||
|
func toBinaryData() -> Data? {
|
||||||
|
toBinaryData(padding: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create binary representation for signing (without signature and TTL fields)
|
||||||
|
/// TTL is excluded because it changes during packet relay operations
|
||||||
|
func toBinaryDataForSigning() -> Data? {
|
||||||
|
// Create a copy without signature and with fixed TTL for signing
|
||||||
|
// TTL must be excluded because it changes during relay
|
||||||
|
let unsignedPacket = BitchatPacket(
|
||||||
|
type: type,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: recipientID,
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil, // Remove signature for signing
|
||||||
|
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
)
|
||||||
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
|
BinaryProtocol.decode(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,61 @@
|
|||||||
|
//
|
||||||
|
// MessagePadding.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||||
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||||
|
struct MessagePadding {
|
||||||
|
// Standard block sizes for padding
|
||||||
|
static let blockSizes = [256, 512, 1024, 2048]
|
||||||
|
|
||||||
|
// Add PKCS#7-style padding to reach target size
|
||||||
|
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
||||||
|
guard data.count < targetSize else { return data }
|
||||||
|
|
||||||
|
let paddingNeeded = targetSize - data.count
|
||||||
|
// Constrain to 255 to fit a single-byte pad length marker
|
||||||
|
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
||||||
|
|
||||||
|
var padded = data
|
||||||
|
// PKCS#7: All pad bytes are equal to the pad length
|
||||||
|
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove padding from data
|
||||||
|
static func unpad(_ data: Data) -> Data {
|
||||||
|
guard !data.isEmpty else { return data }
|
||||||
|
let last = data.last!
|
||||||
|
let paddingLength = Int(last)
|
||||||
|
// Must have at least 1 pad byte and not exceed data length
|
||||||
|
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
||||||
|
// Verify PKCS#7: all last N bytes equal to pad length
|
||||||
|
let start = data.count - paddingLength
|
||||||
|
let tail = data[start...]
|
||||||
|
for b in tail { if b != last { return data } }
|
||||||
|
return Data(data[..<start])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find optimal block size for data
|
||||||
|
static func optimalBlockSize(for dataSize: Int) -> Int {
|
||||||
|
// Account for encryption overhead (~16 bytes for AES-GCM tag)
|
||||||
|
let totalSize = dataSize + 16
|
||||||
|
|
||||||
|
// Find smallest block that fits
|
||||||
|
for blockSize in blockSizes {
|
||||||
|
if totalSize <= blockSize {
|
||||||
|
return blockSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For very large messages, just use the original size
|
||||||
|
// (will be fragmented anyway)
|
||||||
|
return dataSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// NoisePayload.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Helper to create typed Noise payloads
|
||||||
|
struct NoisePayload {
|
||||||
|
let type: NoisePayloadType
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
/// Encode payload with type prefix
|
||||||
|
func encode() -> Data {
|
||||||
|
var encoded = Data()
|
||||||
|
encoded.append(type.rawValue)
|
||||||
|
encoded.append(data)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode payload from data
|
||||||
|
static func decode(_ data: Data) -> NoisePayload? {
|
||||||
|
// Ensure we have at least 1 byte for the type
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely get the first byte
|
||||||
|
let firstByte = data[data.startIndex]
|
||||||
|
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a proper Data copy (not a subsequence) for thread safety
|
||||||
|
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
||||||
|
return NoisePayload(type: type, data: payloadData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short routing IDs (exact 16-hex)
|
||||||
|
var isShort: Bool {
|
||||||
|
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
|
extension PeerID: CustomStringConvertible {
|
||||||
|
/// So it returns the actual `id` like before even inside another String
|
||||||
|
var description: String {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// ReadReceipt.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ReadReceipt: Codable {
|
||||||
|
let originalMessageID: String
|
||||||
|
let receiptID: String
|
||||||
|
var readerID: String // Who read it
|
||||||
|
let readerNickname: String
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||||
|
self.originalMessageID = originalMessageID
|
||||||
|
self.receiptID = UUID().uuidString
|
||||||
|
self.readerID = readerID
|
||||||
|
self.readerNickname = readerNickname
|
||||||
|
self.timestamp = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For binary decoding
|
||||||
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||||
|
self.originalMessageID = originalMessageID
|
||||||
|
self.receiptID = receiptID
|
||||||
|
self.readerID = readerID
|
||||||
|
self.readerNickname = readerNickname
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
try? JSONEncoder().encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> ReadReceipt? {
|
||||||
|
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary Encoding
|
||||||
|
|
||||||
|
func toBinaryData() -> Data {
|
||||||
|
var data = Data()
|
||||||
|
data.appendUUID(originalMessageID)
|
||||||
|
data.appendUUID(receiptID)
|
||||||
|
// ReaderID as 8-byte hex string
|
||||||
|
var readerData = Data()
|
||||||
|
var tempID = readerID
|
||||||
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
|
let hexByte = String(tempID.prefix(2))
|
||||||
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
readerData.append(byte)
|
||||||
|
}
|
||||||
|
tempID = String(tempID.dropFirst(2))
|
||||||
|
}
|
||||||
|
while readerData.count < 8 {
|
||||||
|
readerData.append(0)
|
||||||
|
}
|
||||||
|
data.append(readerData)
|
||||||
|
data.appendDate(timestamp)
|
||||||
|
data.appendString(readerNickname)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
||||||
|
// Create defensive copy
|
||||||
|
let dataCopy = Data(data)
|
||||||
|
|
||||||
|
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
||||||
|
guard dataCopy.count >= 49 else { return nil }
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
||||||
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
|
let readerID = readerIDData.hexEncodedString()
|
||||||
|
guard PeerID(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
||||||
|
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
||||||
|
|
||||||
|
return ReadReceipt(originalMessageID: originalMessageID,
|
||||||
|
receiptID: receiptID,
|
||||||
|
readerID: readerID,
|
||||||
|
readerNickname: readerNickname,
|
||||||
|
timestamp: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||||
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
|
struct RequestSyncPacket {
|
||||||
|
let p: Int
|
||||||
|
let m: UInt32
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
func encode() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
func putTLV(_ t: UInt8, _ v: Data) {
|
||||||
|
out.append(t)
|
||||||
|
let len = UInt16(v.count)
|
||||||
|
out.append(UInt8((len >> 8) & 0xFF))
|
||||||
|
out.append(UInt8(len & 0xFF))
|
||||||
|
out.append(v)
|
||||||
|
}
|
||||||
|
// P
|
||||||
|
putTLV(0x01, Data([UInt8(p & 0xFF)]))
|
||||||
|
// M (uint32)
|
||||||
|
var mBE = m.bigEndian
|
||||||
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
|
// data
|
||||||
|
putTLV(0x03, data)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||||
|
var off = 0
|
||||||
|
var p: Int? = nil
|
||||||
|
var m: UInt32? = nil
|
||||||
|
var payload: Data? = nil
|
||||||
|
|
||||||
|
while off + 3 <= data.count {
|
||||||
|
let t = Int(data[off]); off += 1
|
||||||
|
guard off + 2 <= data.count else { return nil }
|
||||||
|
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
|
||||||
|
guard off + len <= data.count else { return nil }
|
||||||
|
let v = data.subdata(in: off..<(off+len)); off += len
|
||||||
|
switch t {
|
||||||
|
case 0x01:
|
||||||
|
if v.count == 1 { p = Int(v[0]) }
|
||||||
|
case 0x02:
|
||||||
|
if v.count == 4 {
|
||||||
|
var mm: UInt32 = 0
|
||||||
|
for b in v { mm = (mm << 8) | UInt32(b) }
|
||||||
|
m = mm
|
||||||
|
}
|
||||||
|
case 0x03:
|
||||||
|
if v.count > maxAcceptBytes { return nil }
|
||||||
|
payload = v
|
||||||
|
default:
|
||||||
|
break // forward compatible; ignore unknown TLVs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
//
|
|
||||||
// NoiseHandshakeCoordinator.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
|
||||||
final class NoiseHandshakeCoordinator {
|
|
||||||
|
|
||||||
// MARK: - Handshake State
|
|
||||||
|
|
||||||
enum HandshakeState: Equatable {
|
|
||||||
case idle
|
|
||||||
case waitingToInitiate(since: Date)
|
|
||||||
case initiating(attempt: Int, lastAttempt: Date)
|
|
||||||
case responding(since: Date)
|
|
||||||
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
|
||||||
case established(since: Date)
|
|
||||||
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
|
||||||
|
|
||||||
var isActive: Bool {
|
|
||||||
switch self {
|
|
||||||
case .idle, .established, .failed:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
private var handshakeStates: [String: HandshakeState] = [:]
|
|
||||||
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
private let maxHandshakeAttempts = 3
|
|
||||||
private let handshakeTimeout: TimeInterval = 10.0
|
|
||||||
private let retryDelay: TimeInterval = 2.0
|
|
||||||
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
|
||||||
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
|
|
||||||
private let maxEstablishedSessions = 50 // Limit total established sessions
|
|
||||||
|
|
||||||
// Track handshake messages to detect duplicates
|
|
||||||
private var processedHandshakeMessages: Set<Data> = []
|
|
||||||
private let messageHistoryLimit = 100
|
|
||||||
|
|
||||||
// MARK: - Role Determination
|
|
||||||
|
|
||||||
/// Deterministically determine who should initiate the handshake
|
|
||||||
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
|
||||||
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
|
||||||
// Use simple string comparison for deterministic ordering
|
|
||||||
return myPeerID < remotePeerID ? .initiator : .responder
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if we should initiate handshake with a peer
|
|
||||||
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
// Check if we're already in an active handshake
|
|
||||||
if let state = handshakeStates[remotePeerID], state.isActive {
|
|
||||||
// Check if the handshake is stale and we should force a new one
|
|
||||||
if forceIfStale {
|
|
||||||
switch state {
|
|
||||||
case .initiating(_, let lastAttempt):
|
|
||||||
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
|
|
||||||
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check role
|
|
||||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
|
||||||
if role != .initiator {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've failed recently and can't retry yet
|
|
||||||
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
|
||||||
if !canRetry {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that we're initiating a handshake
|
|
||||||
func recordHandshakeInitiation(peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
|
||||||
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
|
||||||
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that we're responding to a handshake
|
|
||||||
func recordHandshakeResponse(peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
self.handshakeStates[peerID] = .responding(since: Date())
|
|
||||||
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record successful handshake completion
|
|
||||||
func recordHandshakeSuccess(peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
self.handshakeStates[peerID] = .established(since: Date())
|
|
||||||
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record handshake failure
|
|
||||||
func recordHandshakeFailure(peerID: String, reason: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
let attempts = self.getCurrentAttempt(for: peerID)
|
|
||||||
let canRetry = attempts < self.maxHandshakeAttempts
|
|
||||||
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
|
||||||
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if we should accept an incoming handshake initiation
|
|
||||||
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
// If we're already established, reject new handshakes
|
|
||||||
if case .established = handshakeStates[remotePeerID] {
|
|
||||||
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
|
||||||
|
|
||||||
// If we're the initiator and already initiating, this is a race condition
|
|
||||||
if role == .initiator {
|
|
||||||
if case .initiating = handshakeStates[remotePeerID] {
|
|
||||||
// They shouldn't be initiating, but accept it to recover from race condition
|
|
||||||
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're the responder, we should accept
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a duplicate handshake message
|
|
||||||
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
if processedHandshakeMessages.contains(data) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to processed messages with size limit
|
|
||||||
if processedHandshakeMessages.count >= messageHistoryLimit {
|
|
||||||
processedHandshakeMessages.removeAll()
|
|
||||||
}
|
|
||||||
processedHandshakeMessages.insert(data)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get time to wait before next handshake attempt
|
|
||||||
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
guard let state = handshakeStates[peerID] else { return nil }
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case .failed(_, let canRetry, let lastAttempt):
|
|
||||||
if !canRetry { return nil }
|
|
||||||
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
|
||||||
if timeSinceFailure >= retryDelay {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return retryDelay - timeSinceFailure
|
|
||||||
|
|
||||||
case .initiating(_, let lastAttempt):
|
|
||||||
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
|
||||||
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return minTimeBetweenHandshakes - timeSinceAttempt
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset handshake state for a peer
|
|
||||||
func resetHandshakeState(for peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
self.handshakeStates.removeValue(forKey: peerID)
|
|
||||||
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up stale handshake states and old established sessions
|
|
||||||
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
let now = Date()
|
|
||||||
var stalePeerIDs: [String] = []
|
|
||||||
var establishedSessions: [(peerID: String, since: Date)] = []
|
|
||||||
|
|
||||||
for (peerID, state) in handshakeStates {
|
|
||||||
var isStale = false
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case .initiating(_, let lastAttempt):
|
|
||||||
if now.timeIntervalSince(lastAttempt) > staleTimeout {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
case .responding(let since):
|
|
||||||
if now.timeIntervalSince(since) > staleTimeout {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
case .waitingForResponse(_, let timeout):
|
|
||||||
if now > timeout {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
case .established(let since):
|
|
||||||
// Track established sessions for potential cleanup
|
|
||||||
establishedSessions.append((peerID, since))
|
|
||||||
// Clean up very old established sessions
|
|
||||||
if now.timeIntervalSince(since) > establishedSessionTTL {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if isStale {
|
|
||||||
stalePeerIDs.append(peerID)
|
|
||||||
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have too many established sessions, clean up the oldest ones
|
|
||||||
if establishedSessions.count > maxEstablishedSessions {
|
|
||||||
// Sort by age (oldest first)
|
|
||||||
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
|
|
||||||
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
|
|
||||||
|
|
||||||
for i in 0..<sessionsToRemove {
|
|
||||||
let peerID = sortedSessions[i].peerID
|
|
||||||
stalePeerIDs.append(peerID)
|
|
||||||
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up stale states
|
|
||||||
for peerID in stalePeerIDs {
|
|
||||||
handshakeStates.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !stalePeerIDs.isEmpty {
|
|
||||||
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stalePeerIDs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current handshake state
|
|
||||||
func getHandshakeState(for peerID: String) -> HandshakeState {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
return handshakeStates[peerID] ?? .idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current retry count for a peer
|
|
||||||
func getRetryCount(for peerID: String) -> Int {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
switch handshakeStates[peerID] {
|
|
||||||
case .initiating(let attempt, _):
|
|
||||||
return attempt - 1 // Attempts start at 1, retries start at 0
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increment retry count for a peer
|
|
||||||
func incrementRetryCount(for peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
let currentAttempt = self.getCurrentAttempt(for: peerID)
|
|
||||||
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func getCurrentAttempt(for peerID: String) -> Int {
|
|
||||||
switch handshakeStates[peerID] {
|
|
||||||
case .initiating(let attempt, _):
|
|
||||||
return attempt
|
|
||||||
case .failed(_, _, _):
|
|
||||||
// Count previous attempts
|
|
||||||
return 1 // Simplified for now
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log current handshake states for debugging
|
|
||||||
func logHandshakeStates() {
|
|
||||||
handshakeQueue.sync {
|
|
||||||
SecureLogger.debug("=== Handshake States ===", category: .handshake)
|
|
||||||
for (peerID, state) in handshakeStates {
|
|
||||||
let stateDesc: String
|
|
||||||
switch state {
|
|
||||||
case .idle:
|
|
||||||
stateDesc = "idle"
|
|
||||||
case .waitingToInitiate(let since):
|
|
||||||
stateDesc = "waiting to initiate (since \(since))"
|
|
||||||
case .initiating(let attempt, let lastAttempt):
|
|
||||||
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
|
||||||
case .responding(let since):
|
|
||||||
stateDesc = "responding (since: \(since))"
|
|
||||||
case .waitingForResponse(let messages, let timeout):
|
|
||||||
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
|
|
||||||
case .established(let since):
|
|
||||||
stateDesc = "established (since \(since))"
|
|
||||||
case .failed(let reason, let canRetry, let lastAttempt):
|
|
||||||
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
|
||||||
}
|
|
||||||
SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
|
|
||||||
}
|
|
||||||
SecureLogger.debug("========================", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all handshake states - used during panic mode
|
|
||||||
func clearAllHandshakeStates() {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
|
|
||||||
self.handshakeStates.removeAll()
|
|
||||||
self.processedHandshakeMessages.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -396,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
|
||||||
}
|
}
|
||||||
@@ -409,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) {
|
||||||
@@ -490,6 +491,7 @@ final class NoiseSymmetricState {
|
|||||||
final class NoiseHandshakeState {
|
final class NoiseHandshakeState {
|
||||||
private let role: NoiseRole
|
private let role: NoiseRole
|
||||||
private let pattern: NoisePattern
|
private let pattern: NoisePattern
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
private var symmetricState: NoiseSymmetricState
|
private var symmetricState: NoiseSymmetricState
|
||||||
|
|
||||||
// Keys
|
// Keys
|
||||||
@@ -505,9 +507,16 @@ final class NoiseHandshakeState {
|
|||||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||||
private var currentPattern = 0
|
private var currentPattern = 0
|
||||||
|
|
||||||
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
init(
|
||||||
|
role: NoiseRole,
|
||||||
|
pattern: NoisePattern,
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||||
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
|
) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
|
self.keychain = keychain
|
||||||
|
|
||||||
// Initialize static keys
|
// Initialize static keys
|
||||||
if let localKey = localStaticKey {
|
if let localKey = localStaticKey {
|
||||||
@@ -578,7 +587,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
KeychainManager.secureClear(&sharedData)
|
keychain.secureClear(&sharedData)
|
||||||
|
|
||||||
case .es:
|
case .es:
|
||||||
// DH(ephemeral, static) - direction depends on role
|
// DH(ephemeral, static) - direction depends on role
|
||||||
@@ -626,7 +635,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
KeychainManager.secureClear(&sharedData)
|
keychain.secureClear(&sharedData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,7 +723,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
KeychainManager.secureClear(&sharedData)
|
keychain.secureClear(&sharedData)
|
||||||
} else {
|
} else {
|
||||||
guard let localStatic = localStaticPrivate,
|
guard let localStatic = localStaticPrivate,
|
||||||
let remoteEphemeral = remoteEphemeralPublic else {
|
let remoteEphemeral = remoteEphemeralPublic else {
|
||||||
@@ -724,7 +733,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
KeychainManager.secureClear(&sharedData)
|
keychain.secureClear(&sharedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .se:
|
case .se:
|
||||||
@@ -737,7 +746,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
KeychainManager.secureClear(&sharedData)
|
keychain.secureClear(&sharedData)
|
||||||
} else {
|
} else {
|
||||||
guard let localEphemeral = localEphemeralPrivate,
|
guard let localEphemeral = localEphemeralPrivate,
|
||||||
let remoteStatic = remoteStaticPublic else {
|
let remoteStatic = remoteStaticPublic else {
|
||||||
@@ -747,7 +756,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
KeychainManager.secureClear(&sharedData)
|
keychain.secureClear(&sharedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .ss:
|
case .ss:
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
// MARK: - Security Constants
|
||||||
|
|
||||||
@@ -52,11 +52,6 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
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
|
// MARK: - Enhanced Noise Session with Security
|
||||||
@@ -136,8 +131,8 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
// MARK: - Rate Limiter
|
// MARK: - Rate Limiter
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
// Global rate limiting
|
// Global rate limiting
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
@@ -145,7 +140,7 @@ final class NoiseRateLimiter {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
func allowHandshake(from peerID: String) -> Bool {
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
@@ -174,7 +169,7 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowMessage(from peerID: String) -> Bool {
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
@@ -203,12 +198,21 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset(for peerID: String) {
|
func reset(for peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
self.messageTimestamps.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
|
// MARK: - Security Errors
|
||||||
|
|||||||
@@ -6,36 +6,14 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
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 var state: NoiseSessionState = .uninitialized
|
private var state: NoiseSessionState = .uninitialized
|
||||||
private var handshakeState: NoiseHandshakeState?
|
private var handshakeState: NoiseHandshakeState?
|
||||||
private var sendCipher: NoiseCipherState?
|
private var sendCipher: NoiseCipherState?
|
||||||
@@ -52,9 +30,16 @@ class NoiseSession {
|
|||||||
// Thread safety
|
// Thread safety
|
||||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||||
|
|
||||||
init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
init(
|
||||||
|
peerID: PeerID,
|
||||||
|
role: NoiseRole,
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
|
) {
|
||||||
self.peerID = peerID
|
self.peerID = peerID
|
||||||
self.role = role
|
self.role = role
|
||||||
|
self.keychain = keychain
|
||||||
self.localStaticKey = localStaticKey
|
self.localStaticKey = localStaticKey
|
||||||
self.remoteStaticPublicKey = remoteStaticKey
|
self.remoteStaticPublicKey = remoteStaticKey
|
||||||
}
|
}
|
||||||
@@ -71,6 +56,7 @@ class NoiseSession {
|
|||||||
handshakeState = NoiseHandshakeState(
|
handshakeState = NoiseHandshakeState(
|
||||||
role: role,
|
role: role,
|
||||||
pattern: .XX,
|
pattern: .XX,
|
||||||
|
keychain: keychain,
|
||||||
localStaticKey: localStaticKey,
|
localStaticKey: localStaticKey,
|
||||||
remoteStaticKey: nil
|
remoteStaticKey: nil
|
||||||
)
|
)
|
||||||
@@ -98,6 +84,7 @@ class NoiseSession {
|
|||||||
handshakeState = NoiseHandshakeState(
|
handshakeState = NoiseHandshakeState(
|
||||||
role: role,
|
role: role,
|
||||||
pattern: .XX,
|
pattern: .XX,
|
||||||
|
keychain: keychain,
|
||||||
localStaticKey: localStaticKey,
|
localStaticKey: localStaticKey,
|
||||||
remoteStaticKey: nil
|
remoteStaticKey: nil
|
||||||
)
|
)
|
||||||
@@ -130,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 {
|
||||||
@@ -156,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
|
||||||
@@ -230,246 +217,19 @@ class NoiseSession {
|
|||||||
// Clear sent handshake messages
|
// Clear sent handshake messages
|
||||||
for i in 0..<sentHandshakeMessages.count {
|
for i in 0..<sentHandshakeMessages.count {
|
||||||
var message = sentHandshakeMessages[i]
|
var message = sentHandshakeMessages[i]
|
||||||
KeychainManager.secureClear(&message)
|
keychain.secureClear(&message)
|
||||||
}
|
}
|
||||||
sentHandshakeMessages.removeAll()
|
sentHandshakeMessages.removeAll()
|
||||||
|
|
||||||
// Clear handshake hash
|
// Clear handshake hash
|
||||||
if var hash = handshakeHash {
|
if var hash = handshakeHash {
|
||||||
KeychainManager.secureClear(&hash)
|
keychain.secureClear(&hash)
|
||||||
}
|
}
|
||||||
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 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) {
|
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
|
||||||
|
|
||||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
|
||||||
return managerQueue.sync(flags: .barrier) {
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
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 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,
|
|
||||||
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,
|
|
||||||
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,15 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionError: Error {
|
||||||
|
case invalidState
|
||||||
|
case notEstablished
|
||||||
|
case sessionNotFound
|
||||||
|
case handshakeFailed(Error)
|
||||||
|
case alreadyEstablished
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//
|
||||||
|
// 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 createSession(for peerID: PeerID, 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: PeerID) -> NoiseSession? {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSession(for peerID: PeerID) {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
if let session = sessions[peerID] {
|
||||||
|
if session.isEstablished() {
|
||||||
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
|
}
|
||||||
|
// 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() -> [PeerID: NoiseSession] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions.filter { $0.value.isEstablished() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||||
|
return getSession(for: peerID)?.getHandshakeHash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
return PeerID(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -150,13 +150,31 @@ struct NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
static func clearAllAssociations() {
|
static func clearAllAssociations() {
|
||||||
// Delete current Nostr identity
|
let query: [String: Any] = [
|
||||||
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
// Note: We can't efficiently delete all noise-nostr associations
|
var result: AnyObject?
|
||||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
// The important part is deleting the current identity so a new one is generated
|
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)
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
@@ -208,10 +226,7 @@ struct NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// As a final fallback, hash the seed+msg and try again
|
// As a final fallback, hash the seed+msg and try again
|
||||||
var combined = Data()
|
let fallback = (seed + msg).sha256Hash()
|
||||||
combined.append(seed)
|
|
||||||
combined.append(msg)
|
|
||||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
@@ -124,6 +125,28 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
|
static func createGeohashTextNote(
|
||||||
|
content: String,
|
||||||
|
geohash: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
var tags = [["g", geohash]]
|
||||||
|
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
|
tags.append(["n", nickname])
|
||||||
|
}
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: tags,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
@@ -498,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 {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
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
|
||||||
@@ -34,10 +36,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
|
private static let defaultRelaySet = Set(defaultRelays)
|
||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
|
private var allowDefaultRelays: Bool = false
|
||||||
|
private var hasMutualFavorites: Bool = false
|
||||||
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
@@ -46,6 +52,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var subscribeCoalesce: [String: Date] = [:]
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
|
private struct EOSETracker {
|
||||||
|
var pendingRelays: Set<String>
|
||||||
|
var callback: () -> Void
|
||||||
|
var timer: Timer?
|
||||||
|
}
|
||||||
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
// Pending sends held only for relays that are not yet connected.
|
// Pending sends held only for relays that are not yet connected.
|
||||||
private struct PendingSend {
|
private struct PendingSend {
|
||||||
@@ -56,6 +70,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||||
|
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
@@ -69,14 +85,36 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize with default relays
|
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||||
|
applyDefaultRelayPolicy(force: true)
|
||||||
// Deterministic JSON shape for outbound requests
|
// Deterministic JSON shape for outbound requests
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] favorites in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.hasMutualFavorites = !favorites.isEmpty
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
LocationChannelManager.shared.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] state in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let authorized = (state == .authorized)
|
||||||
|
if authorized == self.hasLocationPermission { return }
|
||||||
|
self.hasLocationPermission = authorized
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
|
if shouldUseTor {
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
@@ -91,6 +129,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||||
|
for relay in self.relays {
|
||||||
|
connectToRelay(relay.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
@@ -108,7 +152,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -117,20 +165,21 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let existing = Set(relays.map { $0.url })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in Set(relayUrls) {
|
for url in targets where !existing.contains(url) {
|
||||||
if !existing.contains(url) {
|
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
if connections[url] == nil {
|
for url in targets where connections[url] == nil {
|
||||||
connectToRelay(url)
|
connectToRelay(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer sends until Tor is ready to avoid premature queueing
|
// Defer sends until Tor is ready to avoid premature queueing
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -139,7 +188,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||||
|
guard !targetRelays.isEmpty else { return }
|
||||||
ensureConnections(to: targetRelays)
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Attempt immediate send to relays with active connections; queue the rest
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
@@ -201,8 +252,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
relayUrls: [String]? = nil,
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if messageHandlers[id] != nil {
|
if messageHandlers[id] != nil {
|
||||||
@@ -211,7 +265,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -239,17 +293,38 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||||
|
let urls = allowedRelayList(from: candidateUrls)
|
||||||
// Always queue subscriptions; sending happens when a relay reports connected
|
// Always queue subscriptions; sending happens when a relay reports connected
|
||||||
let existingSet = Set(relays.map { $0.url })
|
let existingSet = Set(relays.map { $0.url })
|
||||||
for url in urls where !existingSet.contains(url) {
|
for url in urls where !existingSet.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
}
|
}
|
||||||
for url in urls {
|
for url in candidateUrls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
map[id] = messageString
|
map[id] = messageString
|
||||||
self.pendingSubscriptions[url] = map
|
self.pendingSubscriptions[url] = map
|
||||||
}
|
}
|
||||||
|
// Initialize EOSE tracking if requested
|
||||||
|
if let onEOSE = onEOSE {
|
||||||
|
if urls.isEmpty {
|
||||||
|
onEOSE()
|
||||||
|
} else {
|
||||||
|
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||||
|
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||||
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let t = self.eoseTrackers[id] {
|
||||||
|
t.timer?.invalidate()
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
onEOSE()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
|
}
|
||||||
|
}
|
||||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||||
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||||
ensureConnections(to: urls)
|
ensureConnections(to: urls)
|
||||||
@@ -264,6 +339,55 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||||
|
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||||
|
if !force && shouldAllow == allowDefaultRelays { return }
|
||||||
|
allowDefaultRelays = shouldAllow
|
||||||
|
if shouldAllow {
|
||||||
|
var existing = Set(relays.map { $0.url })
|
||||||
|
for url in Self.defaultRelays where !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
existing.insert(url)
|
||||||
|
}
|
||||||
|
if networkService.activationAllowed {
|
||||||
|
ensureConnections(to: Self.defaultRelays)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for url in Self.defaultRelays {
|
||||||
|
if let connection = connections[url] {
|
||||||
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
connections.removeValue(forKey: url)
|
||||||
|
subscriptions.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
messageQueueLock.lock()
|
||||||
|
for index in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[index]
|
||||||
|
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: index)
|
||||||
|
} else {
|
||||||
|
messageQueue[index] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||||
|
updateConnectionStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var result: [String] = []
|
||||||
|
for url in urls {
|
||||||
|
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||||
|
if seen.insert(url).inserted {
|
||||||
|
result.append(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/// Unsubscribe from a subscription
|
/// Unsubscribe from a subscription
|
||||||
func unsubscribe(id: String) {
|
func unsubscribe(id: String) {
|
||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
@@ -291,13 +415,15 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +438,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempting to connect to Nostr relay via the proxied session
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
// If Tor is enforced but not ready, delay connection until it is.
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
@@ -418,9 +544,17 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||||
}
|
}
|
||||||
case .eose:
|
case .eose(let subId):
|
||||||
// No-op for now
|
if var tracker = eoseTrackers[subId] {
|
||||||
break
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
|
if tracker.pendingRelays.isEmpty {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
|
tracker.callback()
|
||||||
|
} else {
|
||||||
|
eoseTrackers[subId] = tracker
|
||||||
|
}
|
||||||
|
}
|
||||||
case .ok(let eventId, let success, let reason):
|
case .ok(let eventId, let success, let reason):
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
@@ -489,6 +623,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
|
// If networking is disallowed, do not schedule reconnection
|
||||||
|
if !networkService.activationAllowed {
|
||||||
|
connections.removeValue(forKey: relayUrl)
|
||||||
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
@@ -755,6 +896,16 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For location notes: persistent text notes (kind 1) tagged with geohash
|
||||||
|
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [1]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["g": [geohash]]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -197,7 +197,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 = ""
|
||||||
|
|||||||
@@ -328,236 +328,3 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary encoding for BitchatMessage
|
|
||||||
extension BitchatMessage {
|
|
||||||
func toBinaryPayload() -> Data? {
|
|
||||||
var data = Data()
|
|
||||||
|
|
||||||
// Message format:
|
|
||||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
|
||||||
// - Timestamp: 8 bytes (seconds since epoch)
|
|
||||||
// - ID length: 1 byte
|
|
||||||
// - ID: variable
|
|
||||||
// - Sender length: 1 byte
|
|
||||||
// - Sender: variable
|
|
||||||
// - Content length: 2 bytes
|
|
||||||
// - Content: variable
|
|
||||||
// Optional fields based on flags:
|
|
||||||
// - Original sender length + data
|
|
||||||
// - Recipient nickname length + data
|
|
||||||
// - Sender peer ID length + data
|
|
||||||
// - Mentions array
|
|
||||||
|
|
||||||
var flags: UInt8 = 0
|
|
||||||
if isRelay { flags |= 0x01 }
|
|
||||||
if isPrivate { flags |= 0x02 }
|
|
||||||
if originalSender != nil { flags |= 0x04 }
|
|
||||||
if recipientNickname != nil { flags |= 0x08 }
|
|
||||||
if senderPeerID != nil { flags |= 0x10 }
|
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
|
||||||
|
|
||||||
data.append(flags)
|
|
||||||
|
|
||||||
// Timestamp (in milliseconds)
|
|
||||||
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
|
||||||
// Encode as 8 bytes, big-endian
|
|
||||||
for i in (0..<8).reversed() {
|
|
||||||
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID
|
|
||||||
if let idData = id.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(idData.count, 255)))
|
|
||||||
data.append(idData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sender
|
|
||||||
if let senderData = sender.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(senderData.count, 255)))
|
|
||||||
data.append(senderData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content
|
|
||||||
if let contentData = content.data(using: .utf8) {
|
|
||||||
let length = UInt16(min(contentData.count, 65535))
|
|
||||||
// Encode length as 2 bytes, big-endian
|
|
||||||
data.append(UInt8((length >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(length & 0xFF))
|
|
||||||
data.append(contentData.prefix(Int(length)))
|
|
||||||
} else {
|
|
||||||
data.append(contentsOf: [0, 0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(origData.count, 255)))
|
|
||||||
data.append(origData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(recipData.count, 255)))
|
|
||||||
data.append(recipData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(peerData.count, 255)))
|
|
||||||
data.append(peerData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mentions array
|
|
||||||
if let mentions = mentions {
|
|
||||||
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
|
|
||||||
for mention in mentions.prefix(255) {
|
|
||||||
if let mentionData = mention.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(mentionData.count, 255)))
|
|
||||||
data.append(mentionData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
|
|
||||||
// Create an immutable copy to prevent threading issues
|
|
||||||
let dataCopy = Data(data)
|
|
||||||
|
|
||||||
|
|
||||||
guard dataCopy.count >= 13 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
guard offset < dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let flags = dataCopy[offset]; offset += 1
|
|
||||||
let isRelay = (flags & 0x01) != 0
|
|
||||||
let isPrivate = (flags & 0x02) != 0
|
|
||||||
let hasOriginalSender = (flags & 0x04) != 0
|
|
||||||
let hasRecipientNickname = (flags & 0x08) != 0
|
|
||||||
let hasSenderPeerID = (flags & 0x10) != 0
|
|
||||||
let hasMentions = (flags & 0x20) != 0
|
|
||||||
|
|
||||||
// Timestamp
|
|
||||||
guard offset + 8 <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let timestampData = dataCopy[offset..<offset+8]
|
|
||||||
let timestampMillis = timestampData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
offset += 8
|
|
||||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
|
|
||||||
|
|
||||||
// ID
|
|
||||||
guard offset < dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let idLength = Int(dataCopy[offset]); offset += 1
|
|
||||||
guard offset + idLength <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
|
|
||||||
offset += idLength
|
|
||||||
|
|
||||||
// Sender
|
|
||||||
guard offset < dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let senderLength = Int(dataCopy[offset]); offset += 1
|
|
||||||
guard offset + senderLength <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
|
|
||||||
offset += senderLength
|
|
||||||
|
|
||||||
// Content
|
|
||||||
guard offset + 2 <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let contentLengthData = dataCopy[offset..<offset+2]
|
|
||||||
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt16(byte)
|
|
||||||
})
|
|
||||||
offset += 2
|
|
||||||
guard offset + contentLength <= dataCopy.count else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
|
|
||||||
offset += contentLength
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
var originalSender: String?
|
|
||||||
if hasOriginalSender && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var recipientNickname: String?
|
|
||||||
if hasRecipientNickname && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var senderPeerID: String?
|
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mentions array
|
|
||||||
var mentions: [String]?
|
|
||||||
if hasMentions && offset < dataCopy.count {
|
|
||||||
let mentionCount = Int(dataCopy[offset]); offset += 1
|
|
||||||
if mentionCount > 0 {
|
|
||||||
mentions = []
|
|
||||||
for _ in 0..<mentionCount {
|
|
||||||
if offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
|
||||||
mentions?.append(mention)
|
|
||||||
}
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: id,
|
|
||||||
sender: sender,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: isRelay,
|
|
||||||
originalSender: originalSender,
|
|
||||||
isPrivate: isPrivate,
|
|
||||||
recipientNickname: recipientNickname,
|
|
||||||
senderPeerID: senderPeerID,
|
|
||||||
mentions: mentions
|
|
||||||
)
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,61 +59,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CoreBluetooth
|
||||||
|
|
||||||
// MARK: - Message Padding
|
|
||||||
|
|
||||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
|
||||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
|
||||||
struct MessagePadding {
|
|
||||||
// Standard block sizes for padding
|
|
||||||
static let blockSizes = [256, 512, 1024, 2048]
|
|
||||||
|
|
||||||
// Add PKCS#7-style padding to reach target size
|
|
||||||
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
|
||||||
guard data.count < targetSize else { return data }
|
|
||||||
|
|
||||||
let paddingNeeded = targetSize - data.count
|
|
||||||
// Constrain to 255 to fit a single-byte pad length marker
|
|
||||||
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
|
||||||
|
|
||||||
var padded = data
|
|
||||||
// PKCS#7: All pad bytes are equal to the pad length
|
|
||||||
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
|
||||||
return padded
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove padding from data
|
|
||||||
static func unpad(_ data: Data) -> Data {
|
|
||||||
guard !data.isEmpty else { return data }
|
|
||||||
let last = data.last!
|
|
||||||
let paddingLength = Int(last)
|
|
||||||
// Must have at least 1 pad byte and not exceed data length
|
|
||||||
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
|
||||||
// Verify PKCS#7: all last N bytes equal to pad length
|
|
||||||
let start = data.count - paddingLength
|
|
||||||
let tail = data[start...]
|
|
||||||
for b in tail { if b != last { return data } }
|
|
||||||
return Data(data[..<start])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find optimal block size for data
|
|
||||||
static func optimalBlockSize(for dataSize: Int) -> Int {
|
|
||||||
// Account for encryption overhead (~16 bytes for AES-GCM tag)
|
|
||||||
let totalSize = dataSize + 16
|
|
||||||
|
|
||||||
// Find smallest block that fits
|
|
||||||
for blockSize in blockSizes {
|
|
||||||
if totalSize <= blockSize {
|
|
||||||
return blockSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For very large messages, just use the original size
|
|
||||||
// (will be fragmented anyway)
|
|
||||||
return dataSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
@@ -125,6 +71,7 @@ enum MessageType: UInt8 {
|
|||||||
case announce = 0x01 // "I'm here" with nickname
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
case message = 0x02 // Public chat message
|
case message = 0x02 // Public chat message
|
||||||
case leave = 0x03 // "I'm leaving"
|
case leave = 0x03 // "I'm leaving"
|
||||||
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
// Noise encryption
|
// Noise encryption
|
||||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||||
@@ -138,6 +85,7 @@ enum MessageType: UInt8 {
|
|||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
case .message: return "message"
|
case .message: return "message"
|
||||||
case .leave: return "leave"
|
case .leave: return "leave"
|
||||||
|
case .requestSync: return "requestSync"
|
||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
@@ -181,191 +129,10 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
// MARK: - Core Protocol Structures
|
|
||||||
|
|
||||||
/// The core packet structure for all BitChat protocol messages.
|
|
||||||
/// Encapsulates all data needed for routing through the mesh network,
|
|
||||||
/// including TTL for hop limiting and optional encryption.
|
|
||||||
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
|
||||||
struct BitchatPacket: Codable {
|
|
||||||
let version: UInt8
|
|
||||||
let type: UInt8
|
|
||||||
let senderID: Data
|
|
||||||
let recipientID: Data?
|
|
||||||
let timestamp: UInt64
|
|
||||||
let payload: Data
|
|
||||||
var signature: Data?
|
|
||||||
var ttl: UInt8
|
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
|
||||||
self.version = 1
|
|
||||||
self.type = type
|
|
||||||
self.senderID = senderID
|
|
||||||
self.recipientID = recipientID
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.payload = payload
|
|
||||||
self.signature = signature
|
|
||||||
self.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
|
||||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
|
||||||
self.version = 1
|
|
||||||
self.type = type
|
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
|
||||||
var senderData = Data()
|
|
||||||
var tempID = senderID
|
|
||||||
while tempID.count >= 2 {
|
|
||||||
let hexByte = String(tempID.prefix(2))
|
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
|
||||||
senderData.append(byte)
|
|
||||||
}
|
|
||||||
tempID = String(tempID.dropFirst(2))
|
|
||||||
}
|
|
||||||
self.senderID = senderData
|
|
||||||
self.recipientID = nil
|
|
||||||
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
|
||||||
self.payload = payload
|
|
||||||
self.signature = nil
|
|
||||||
self.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
var data: Data? {
|
|
||||||
BinaryProtocol.encode(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBinaryData(padding: Bool = true) -> Data? {
|
|
||||||
BinaryProtocol.encode(self, padding: padding)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward-compatible helper (defaults to padded encoding)
|
|
||||||
func toBinaryData() -> Data? {
|
|
||||||
toBinaryData(padding: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create binary representation for signing (without signature and TTL fields)
|
|
||||||
/// TTL is excluded because it changes during packet relay operations
|
|
||||||
func toBinaryDataForSigning() -> Data? {
|
|
||||||
// Create a copy without signature and with fixed TTL for signing
|
|
||||||
// TTL must be excluded because it changes during relay
|
|
||||||
let unsignedPacket = BitchatPacket(
|
|
||||||
type: type,
|
|
||||||
senderID: senderID,
|
|
||||||
recipientID: recipientID,
|
|
||||||
timestamp: timestamp,
|
|
||||||
payload: payload,
|
|
||||||
signature: nil, // Remove signature for signing
|
|
||||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
|
||||||
)
|
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func from(_ data: Data) -> BitchatPacket? {
|
|
||||||
BinaryProtocol.decode(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
// MARK: - Read Receipts
|
|
||||||
|
|
||||||
// Read receipt structure
|
|
||||||
struct ReadReceipt: Codable {
|
|
||||||
let originalMessageID: String
|
|
||||||
let receiptID: String
|
|
||||||
var readerID: String // Who read it
|
|
||||||
let readerNickname: String
|
|
||||||
let timestamp: Date
|
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
|
||||||
self.originalMessageID = originalMessageID
|
|
||||||
self.receiptID = UUID().uuidString
|
|
||||||
self.readerID = readerID
|
|
||||||
self.readerNickname = readerNickname
|
|
||||||
self.timestamp = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// For binary decoding
|
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
|
||||||
self.originalMessageID = originalMessageID
|
|
||||||
self.receiptID = receiptID
|
|
||||||
self.readerID = readerID
|
|
||||||
self.readerNickname = readerNickname
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data? {
|
|
||||||
try? JSONEncoder().encode(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(from data: Data) -> ReadReceipt? {
|
|
||||||
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Binary Encoding
|
|
||||||
|
|
||||||
func toBinaryData() -> Data {
|
|
||||||
var data = Data()
|
|
||||||
data.appendUUID(originalMessageID)
|
|
||||||
data.appendUUID(receiptID)
|
|
||||||
// ReaderID as 8-byte hex string
|
|
||||||
var readerData = Data()
|
|
||||||
var tempID = readerID
|
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
|
||||||
let hexByte = String(tempID.prefix(2))
|
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
|
||||||
readerData.append(byte)
|
|
||||||
}
|
|
||||||
tempID = String(tempID.dropFirst(2))
|
|
||||||
}
|
|
||||||
while readerData.count < 8 {
|
|
||||||
readerData.append(0)
|
|
||||||
}
|
|
||||||
data.append(readerData)
|
|
||||||
data.appendDate(timestamp)
|
|
||||||
data.appendString(readerNickname)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
|
||||||
// Create defensive copy
|
|
||||||
let dataCopy = Data(data)
|
|
||||||
|
|
||||||
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
|
||||||
guard dataCopy.count >= 49 else { return nil }
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
|
||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
||||||
let readerID = readerIDData.hexEncodedString()
|
|
||||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
|
||||||
InputValidator.validateTimestamp(timestamp),
|
|
||||||
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
|
||||||
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
|
||||||
|
|
||||||
return ReadReceipt(originalMessageID: originalMessageID,
|
|
||||||
receiptID: receiptID,
|
|
||||||
readerID: readerID,
|
|
||||||
readerNickname: readerNickname,
|
|
||||||
timestamp: timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -391,81 +158,14 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Model
|
|
||||||
|
|
||||||
/// Represents a user-visible message in the BitChat system.
|
|
||||||
/// Handles both broadcast messages and private encrypted messages,
|
|
||||||
/// with support for mentions, replies, and delivery tracking.
|
|
||||||
/// - Note: This is the primary data model for chat messages
|
|
||||||
final class BitchatMessage: Codable {
|
|
||||||
let id: String
|
|
||||||
let sender: String
|
|
||||||
let content: String
|
|
||||||
let timestamp: Date
|
|
||||||
let isRelay: Bool
|
|
||||||
let originalSender: String?
|
|
||||||
let isPrivate: Bool
|
|
||||||
let recipientNickname: String?
|
|
||||||
let senderPeerID: String?
|
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
|
||||||
|
|
||||||
// Cached formatted text (not included in Codable)
|
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
|
||||||
|
|
||||||
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
|
||||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
|
||||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codable implementation
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id, sender, content, timestamp, isRelay, originalSender
|
|
||||||
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) {
|
|
||||||
self.id = id ?? UUID().uuidString
|
|
||||||
self.sender = sender
|
|
||||||
self.content = content
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.isRelay = isRelay
|
|
||||||
self.originalSender = originalSender
|
|
||||||
self.isPrivate = isPrivate
|
|
||||||
self.recipientNickname = recipientNickname
|
|
||||||
self.senderPeerID = senderPeerID
|
|
||||||
self.mentions = mentions
|
|
||||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equatable conformance for BitchatMessage
|
|
||||||
extension BitchatMessage: Equatable {
|
|
||||||
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
|
||||||
return lhs.id == rhs.id &&
|
|
||||||
lhs.sender == rhs.sender &&
|
|
||||||
lhs.content == rhs.content &&
|
|
||||||
lhs.timestamp == rhs.timestamp &&
|
|
||||||
lhs.isRelay == rhs.isRelay &&
|
|
||||||
lhs.originalSender == rhs.originalSender &&
|
|
||||||
lhs.isPrivate == rhs.isPrivate &&
|
|
||||||
lhs.recipientNickname == rhs.recipientNickname &&
|
|
||||||
lhs.senderPeerID == rhs.senderPeerID &&
|
|
||||||
lhs.mentions == rhs.mentions &&
|
|
||||||
lhs.deliveryStatus == rhs.deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
|
@MainActor
|
||||||
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
|
||||||
@@ -473,11 +173,15 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
|
@MainActor
|
||||||
extension BitchatDelegate {
|
extension BitchatDelegate {
|
||||||
func isFavorite(fingerprint: String) -> Bool {
|
func isFavorite(fingerprint: String) -> Bool {
|
||||||
return false
|
return false
|
||||||
@@ -487,45 +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) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noise Payload Helpers
|
|
||||||
|
|
||||||
/// Helper to create typed Noise payloads
|
|
||||||
struct NoisePayload {
|
|
||||||
let type: NoisePayloadType
|
|
||||||
let data: Data
|
|
||||||
|
|
||||||
/// Encode payload with type prefix
|
|
||||||
func encode() -> Data {
|
|
||||||
var encoded = Data()
|
|
||||||
encoded.append(type.rawValue)
|
|
||||||
encoded.append(data)
|
|
||||||
return encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode payload from data
|
|
||||||
static func decode(_ data: Data) -> NoisePayload? {
|
|
||||||
// Ensure we have at least 1 byte for the type
|
|
||||||
guard !data.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely get the first byte
|
|
||||||
let firstByte = data[data.startIndex]
|
|
||||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a proper Data copy (not a subsequence) for thread safety
|
|
||||||
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
|
||||||
return NoisePayload(type: type, data: payloadData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
/// Levels of location channels mapped to geohash precisions.
|
/// Levels of location channels mapped to geohash precisions.
|
||||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||||
|
case building
|
||||||
case block
|
case block
|
||||||
case neighborhood
|
case neighborhood
|
||||||
case city
|
case city
|
||||||
@@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
/// Geohash length used for this level.
|
/// Geohash length used for this level.
|
||||||
var precision: Int {
|
var precision: Int {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .building: return 8
|
||||||
case .block: return 7
|
case .block: return 7
|
||||||
case .neighborhood: return 6
|
case .neighborhood: return 6
|
||||||
case .city: return 5
|
case .city: return 5
|
||||||
@@ -21,13 +23,20 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .block: return "Block"
|
case .building:
|
||||||
case .neighborhood: return "Neighborhood"
|
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||||
case .city: return "City"
|
case .block:
|
||||||
case .province: return "Province"
|
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||||
case .region: return "Region"
|
case .neighborhood:
|
||||||
|
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 {
|
||||||
@@ -35,6 +44,7 @@ extension GeohashChannelLevel {
|
|||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if let raw = try? container.decode(String.self) {
|
if let raw = try? container.decode(String.self) {
|
||||||
switch raw {
|
switch raw {
|
||||||
|
case "building": self = .building
|
||||||
case "block": self = .block
|
case "block": self = .block
|
||||||
case "neighborhood": self = .neighborhood
|
case "neighborhood": self = .neighborhood
|
||||||
case "city": self = .city
|
case "city": self = .city
|
||||||
@@ -46,6 +56,7 @@ extension GeohashChannelLevel {
|
|||||||
}
|
}
|
||||||
} else if let precision = try? container.decode(Int.self) {
|
} else if let precision = try? container.decode(Int.self) {
|
||||||
switch precision {
|
switch precision {
|
||||||
|
case 8: self = .building
|
||||||
case 7: self = .block
|
case 7: self = .block
|
||||||
case 6: self = .neighborhood
|
case 6: self = .neighborhood
|
||||||
case 5: self = .city
|
case 5: self = .city
|
||||||
@@ -61,6 +72,7 @@ extension GeohashChannelLevel {
|
|||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
switch self {
|
switch self {
|
||||||
|
case .building: try container.encode("building")
|
||||||
case .block: try container.encode("block")
|
case .block: try container.encode("block")
|
||||||
case .neighborhood: try container.encode("neighborhood")
|
case .neighborhood: try container.encode("neighborhood")
|
||||||
case .city: try container.encode("city")
|
case .city: try container.encode("city")
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+2098
-1896
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,12 @@ enum CommandResult {
|
|||||||
final class CommandProcessor {
|
final class CommandProcessor {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var chatViewModel: ChatViewModel?
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) {
|
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.identityManager = identityManager
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a command string
|
/// Process a command string
|
||||||
@@ -146,9 +148,9 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: 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
|
||||||
@@ -189,7 +191,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||||
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = chatViewModel {
|
if let vm = chatViewModel {
|
||||||
let visible = vm.visibleGeohashPeople()
|
let visible = vm.visibleGeohashPeople()
|
||||||
@@ -212,15 +214,15 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
// Block the user (mesh/noise identity)
|
// Block the user (mesh/noise identity)
|
||||||
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
if var identity = identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
identity.isBlocked = true
|
identity.isBlocked = true
|
||||||
identity.isFavorite = false
|
identity.isFavorite = false
|
||||||
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
identityManager.updateSocialIdentity(identity)
|
||||||
} else {
|
} else {
|
||||||
let blockedIdentity = SocialIdentity(
|
let blockedIdentity = SocialIdentity(
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
@@ -231,16 +233,16 @@ final class CommandProcessor {
|
|||||||
isBlocked: true,
|
isBlocked: true,
|
||||||
notes: nil
|
notes: nil
|
||||||
)
|
)
|
||||||
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
identityManager.updateSocialIdentity(blockedIdentity)
|
||||||
}
|
}
|
||||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||||
}
|
}
|
||||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true)
|
identityManager.setNostrBlocked(pub, isBlocked: true)
|
||||||
return .success(message: "blocked \(nickname) in geohash chats")
|
return .success(message: "blocked \(nickname) in geohash chats")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,19 +258,19 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
identityManager.setBlocked(fingerprint, isBlocked: false)
|
||||||
return .success(message: "unblocked \(nickname)")
|
return .success(message: "unblocked \(nickname)")
|
||||||
}
|
}
|
||||||
// Try geohash unblock
|
// Try geohash unblock
|
||||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false)
|
identityManager.setNostrBlocked(pub, isBlocked: false)
|
||||||
return .success(message: "unblocked \(nickname) in geohash chats")
|
return .success(message: "unblocked \(nickname) in geohash chats")
|
||||||
}
|
}
|
||||||
return .error(message: "cannot unblock \(nickname): not found")
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@@ -13,12 +14,16 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let theyFavoritedUs: Bool
|
let theyFavoritedUs: Bool
|
||||||
let favoritedAt: Date
|
let favoritedAt: Date
|
||||||
let lastUpdated: Date
|
let lastUpdated: Date
|
||||||
|
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
|
||||||
|
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
|
||||||
|
|
||||||
var isMutual: Bool {
|
var isMutual: Bool {
|
||||||
isFavorite && theyFavoritedUs
|
isFavorite && theyFavoritedUs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
@@ -174,125 +179,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)
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private init() {
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,26 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
final class KeychainManager {
|
protocol KeychainManagerProtocol {
|
||||||
static let shared = KeychainManager()
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
|
func deleteIdentityKey(forKey key: String) -> Bool
|
||||||
|
func deleteAllKeychainData() -> Bool
|
||||||
|
|
||||||
|
func secureClear(_ data: inout Data)
|
||||||
|
func secureClear(_ string: inout String)
|
||||||
|
|
||||||
|
func verifyIdentityKeyExists() -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = "chat.bitchat"
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.chat.bitchat"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
|
|
||||||
private func isSandboxed() -> Bool {
|
private func isSandboxed() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -274,6 +282,7 @@ final class KeychainManager {
|
|||||||
"com.bitchat.deviceidentity",
|
"com.bitchat.deviceidentity",
|
||||||
"com.bitchat.noise.identity",
|
"com.bitchat.noise.identity",
|
||||||
"chat.bitchat.passwords",
|
"chat.bitchat.passwords",
|
||||||
|
"chat.bitchat.nostr",
|
||||||
"bitchat.keychain",
|
"bitchat.keychain",
|
||||||
"bitchat",
|
"bitchat",
|
||||||
"com.bitchat"
|
"com.bitchat"
|
||||||
@@ -310,7 +319,7 @@ final class KeychainManager {
|
|||||||
// MARK: - Security Utilities
|
// MARK: - Security Utilities
|
||||||
|
|
||||||
/// Securely clear sensitive data from memory
|
/// Securely clear sensitive data from memory
|
||||||
static func secureClear(_ data: inout Data) {
|
func secureClear(_ data: inout Data) {
|
||||||
_ = data.withUnsafeMutableBytes { bytes in
|
_ = data.withUnsafeMutableBytes { bytes in
|
||||||
// Use volatile memset to prevent compiler optimization
|
// Use volatile memset to prevent compiler optimization
|
||||||
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
|
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
|
||||||
@@ -319,7 +328,7 @@ final class KeychainManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Securely clear sensitive string from memory
|
/// Securely clear sensitive string from memory
|
||||||
static func secureClear(_ string: inout String) {
|
func secureClear(_ string: inout String) {
|
||||||
// Convert to mutable data and clear
|
// Convert to mutable data and clear
|
||||||
if var data = string.data(using: .utf8) {
|
if var data = string.data(using: .utf8) {
|
||||||
secureClear(&data)
|
secureClear(&data)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@@ -285,12 +286,18 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
dict[.neighborhood] = locality
|
dict[.neighborhood] = locality
|
||||||
}
|
}
|
||||||
// Block: reuse neighborhood/locality granularity without exposing street level
|
// Block: reuse neighborhood/locality granularity
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
dict[.block] = subLocality
|
dict[.block] = subLocality
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
dict[.block] = locality
|
dict[.block] = locality
|
||||||
}
|
}
|
||||||
|
// Building: prefer place name/street/venue when available
|
||||||
|
if let name = pm.name, !name.isEmpty {
|
||||||
|
dict[.building] = name
|
||||||
|
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||||
|
dict[.building] = thoroughfare
|
||||||
|
}
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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 }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
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: 200)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||||
|
struct LocationNotesDependencies {
|
||||||
|
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
|
||||||
|
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
|
||||||
|
|
||||||
|
var relayLookup: RelayLookup
|
||||||
|
var subscribe: Subscribe
|
||||||
|
var unsubscribe: Unsubscribe
|
||||||
|
var sendEvent: SendEvent
|
||||||
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
|
var now: () -> Date
|
||||||
|
|
||||||
|
static let live = LocationNotesDependencies(
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
sendEvent: { event, relays in
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
|
},
|
||||||
|
deriveIdentity: { geohash in
|
||||||
|
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||||
|
},
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
|
||||||
|
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesManager: ObservableObject {
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case noRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Note: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let pubkey: String
|
||||||
|
let content: String
|
||||||
|
let createdAt: Date
|
||||||
|
let nickname: String?
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
let suffix = String(pubkey.suffix(4))
|
||||||
|
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return "\(nick)#\(suffix)"
|
||||||
|
}
|
||||||
|
return "anon#\(suffix)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
||||||
|
@Published private(set) var geohash: String
|
||||||
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var state: State = .loading
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
private var subscriptionID: String?
|
||||||
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
|
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) {
|
||||||
|
let norm = geohash.lowercased()
|
||||||
|
self.geohash = norm
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGeohash(_ newGeohash: String) {
|
||||||
|
let norm = newGeohash.lowercased()
|
||||||
|
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 {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
|
geohash = norm
|
||||||
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribe() {
|
||||||
|
state = .loading
|
||||||
|
errorMessage = nil
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
subscriptionID = nil
|
||||||
|
initialLoadComplete = true
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionID = subID
|
||||||
|
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)
|
||||||
|
|
||||||
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
|
// Ensure matching tag
|
||||||
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) 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 ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
|
self.notes.append(note)
|
||||||
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
|
self.enforceMemoryCap()
|
||||||
|
self.state = .ready
|
||||||
|
}, { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.initialLoadComplete = true
|
||||||
|
if self.state != .noRelays {
|
||||||
|
self.state = .ready
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a location note for the current geohash using the per-geohash identity.
|
||||||
|
func send(content: String, nickname: String) {
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let id = try dependencies.deriveIdentity(geohash)
|
||||||
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
|
content: trimmed,
|
||||||
|
geohash: geohash,
|
||||||
|
senderIdentity: id,
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
dependencies.sendEvent(event, relays)
|
||||||
|
// Optimistic local-echo
|
||||||
|
let echo = Note(
|
||||||
|
id: event.id,
|
||||||
|
pubkey: id.publicKeyHex,
|
||||||
|
content: trimmed,
|
||||||
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.enforceMemoryCap()
|
||||||
|
self.state = .ready
|
||||||
|
self.errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly cancel subscription and release resources.
|
||||||
|
func cancel() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
state = .idle
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Routes messages between BLE and Nostr transports
|
/// Routes messages between BLE and Nostr transports
|
||||||
@@ -5,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
|
||||||
@@ -20,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)
|
||||||
}
|
}
|
||||||
@@ -28,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)
|
||||||
}
|
}
|
||||||
@@ -36,44 +37,45 @@ 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
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
} else {
|
} else {
|
||||||
@@ -82,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
|
||||||
@@ -100,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
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
|
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||||
|
@MainActor
|
||||||
|
final class NetworkActivationService: ObservableObject {
|
||||||
|
static let shared = NetworkActivationService()
|
||||||
|
|
||||||
|
@Published private(set) var activationAllowed: Bool = false
|
||||||
|
@Published private(set) var userTorEnabled: Bool = true
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var started = false
|
||||||
|
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||||
|
private var torAutoStartDesired: Bool = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
|
||||||
|
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||||
|
userTorEnabled = stored
|
||||||
|
} else {
|
||||||
|
userTorEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial compute
|
||||||
|
let allowed = basePolicyAllowed()
|
||||||
|
activationAllowed = allowed
|
||||||
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
|
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||||
|
applyTorState(torDesired: torAutoStartDesired)
|
||||||
|
if allowed {
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to location permission changes
|
||||||
|
LocationChannelManager.shared.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reevaluate()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// React to mutual favorites changes
|
||||||
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reevaluate()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
|
guard enabled != userTorEnabled else { return }
|
||||||
|
userTorEnabled = enabled
|
||||||
|
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .TorUserPreferenceChanged,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["enabled": enabled]
|
||||||
|
)
|
||||||
|
reevaluate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reevaluate() {
|
||||||
|
let allowed = basePolicyAllowed()
|
||||||
|
let torDesired = allowed && userTorEnabled
|
||||||
|
let statusChanged = allowed != activationAllowed
|
||||||
|
let torChanged = torDesired != torAutoStartDesired
|
||||||
|
if statusChanged {
|
||||||
|
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
||||||
|
activationAllowed = allowed
|
||||||
|
}
|
||||||
|
if statusChanged || torChanged {
|
||||||
|
torAutoStartDesired = torDesired
|
||||||
|
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||||
|
applyTorState(torDesired: torDesired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
if torChanged {
|
||||||
|
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
} else if statusChanged {
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func basePolicyAllowed() -> Bool {
|
||||||
|
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||||
|
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
|
return permOK || hasMutual
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTorState(torDesired: Bool) {
|
||||||
|
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||||
|
if torDesired {
|
||||||
|
TorManager.shared.startIfNeeded()
|
||||||
|
} else {
|
||||||
|
TorManager.shared.shutdownCompletely()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,6 @@
|
|||||||
/// ## Integration Points
|
/// ## Integration Points
|
||||||
/// - **BLEService**: Calls this service for all private messages
|
/// - **BLEService**: Calls this service for all private messages
|
||||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
|
||||||
/// - **KeychainManager**: Secure storage for identity keys
|
/// - **KeychainManager**: Secure storage for identity keys
|
||||||
///
|
///
|
||||||
/// ## Thread Safety
|
/// ## Thread Safety
|
||||||
@@ -83,6 +82,7 @@
|
|||||||
/// - Background queue for CPU-intensive operations
|
/// - Background queue for CPU-intensive operations
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -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,14 +162,15 @@ 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)
|
||||||
|
|
||||||
// Security components
|
// Security components
|
||||||
private let rateLimiter = NoiseRateLimiter()
|
private let rateLimiter = NoiseRateLimiter()
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
// Session maintenance
|
// Session maintenance
|
||||||
private var rekeyTimer: Timer?
|
private var rekeyTimer: Timer?
|
||||||
@@ -162,7 +178,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((String, 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 (String, String) -> Void) {
|
||||||
@@ -181,12 +197,14 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
|
self.keychain = keychain
|
||||||
|
|
||||||
// Load or create static identity key (ONLY from keychain)
|
// Load or create static identity key (ONLY from keychain)
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain
|
// Try to load from keychain
|
||||||
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
|
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
||||||
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
loadedKey = key
|
loadedKey = key
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||||
@@ -197,7 +215,7 @@ final class NoiseEncryptionService {
|
|||||||
let keyData = loadedKey.rawRepresentation
|
let keyData = loadedKey.rawRepresentation
|
||||||
|
|
||||||
// Save to keychain
|
// Save to keychain
|
||||||
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +227,7 @@ final class NoiseEncryptionService {
|
|||||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain
|
// Try to load from keychain
|
||||||
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
|
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||||
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
loadedSigningKey = key
|
loadedSigningKey = key
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||||
@@ -220,7 +238,7 @@ final class NoiseEncryptionService {
|
|||||||
let keyData = loadedSigningKey.rawRepresentation
|
let keyData = loadedSigningKey.rawRepresentation
|
||||||
|
|
||||||
// Save to keychain
|
// Save to keychain
|
||||||
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +247,7 @@ final class NoiseEncryptionService {
|
|||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
@@ -254,20 +272,19 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear persistent identity (for panic mode)
|
/// Clear persistent identity (for panic mode)
|
||||||
func clearPersistentIdentity() {
|
func clearPersistentIdentity() {
|
||||||
// Clear from keychain
|
// Clear from keychain
|
||||||
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
|
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||||
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
|
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
||||||
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||||
// Stop rekey timer
|
// Stop rekey timer
|
||||||
@@ -387,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,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
|
||||||
@@ -410,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,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
|
||||||
@@ -474,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
|
||||||
@@ -496,38 +513,26 @@ 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 clearEphemeralStateForPanic() {
|
||||||
func getPeerID(for fingerprint: String) -> String? {
|
sessionManager.removeAllSessions()
|
||||||
return serviceQueue.sync {
|
|
||||||
return fingerprintToPeerID[fingerprint]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a peer session
|
|
||||||
func removePeer(_ peerID: String) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
if let fingerprint = peerFingerprints[peerID] {
|
peerFingerprints.removeAll()
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
fingerprintToPeerID.removeAll()
|
||||||
}
|
}
|
||||||
peerFingerprints.removeValue(forKey: peerID)
|
rateLimiter.resetAll()
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -536,21 +541,16 @@ 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
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peerID.id, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
private func startRekeyTimer() {
|
private func startRekeyTimer() {
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
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 {
|
||||||
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
|
private struct QueuedRead {
|
||||||
|
let receipt: ReadReceipt
|
||||||
|
let peerID: PeerID
|
||||||
|
}
|
||||||
|
private var readQueue: [QueuedRead] = []
|
||||||
|
private var isSendingReadAcks = false
|
||||||
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
|
init(keychain: KeychainManagerProtocol) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
Just([]).eraseToAnyPublisher()
|
Just([]).eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||||
|
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
var myPeerID: PeerID { senderPeerID }
|
||||||
var senderPeerID: String = ""
|
|
||||||
|
|
||||||
// Throttle READ receipts to avoid relay rate limits
|
|
||||||
private struct QueuedRead {
|
|
||||||
let receipt: ReadReceipt
|
|
||||||
let peerID: String
|
|
||||||
}
|
|
||||||
private var readQueue: [QueuedRead] = []
|
|
||||||
private var isSendingReadAcks = false
|
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||||
|
|
||||||
@@ -30,28 +39,35 @@ final class NostrTransport: Transport {
|
|||||||
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: PeerID) -> String? { nil }
|
||||||
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||||
|
|
||||||
func getFingerprint(for peerID: String) -> String? { nil }
|
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
|
||||||
func triggerHandshake(with peerID: String) { /* 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?
|
||||||
NoiseEncryptionService()
|
private static let noiseServiceLock = NSLock()
|
||||||
}()
|
func getNoiseService() -> NoiseEncryptionService {
|
||||||
func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService }
|
Self.noiseServiceLock.lock()
|
||||||
|
defer { Self.noiseServiceLock.unlock() }
|
||||||
|
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||||
|
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
|
Self.cachedNoiseService = noiseService
|
||||||
|
return noiseService
|
||||||
|
}
|
||||||
|
|
||||||
// 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? NostrIdentityBridge.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 {
|
||||||
@@ -65,7 +81,7 @@ final class NostrTransport: Transport {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,12 +94,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? 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.id, senderPeerID: senderPeerID.id) 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? 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.id, senderPeerID: senderPeerID.id) 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.id) 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.id) 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.id) 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 }
|
||||||
@@ -105,7 +222,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
@@ -127,111 +244,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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||||
let title = "🔒 private message 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, "senderName": sender]
|
||||||
@@ -70,26 +70,6 @@ final class NotificationService {
|
|||||||
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)"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// NotificationStreamAssembler.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NotificationStreamAssembler {
|
||||||
|
private var buffer = Data()
|
||||||
|
|
||||||
|
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 reset = false
|
||||||
|
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||||
|
|
||||||
|
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||||
|
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||||
|
|
||||||
|
while buffer.count >= minFramePrefix {
|
||||||
|
guard let first = buffer.first else { break }
|
||||||
|
if first != 1 {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard buffer.count >= minHeaderBytes else { break }
|
||||||
|
|
||||||
|
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||||
|
guard headerBytes.count == minFramePrefix else { break }
|
||||||
|
|
||||||
|
let flags = headerBytes[11]
|
||||||
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
|
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||||
|
|
||||||
|
var frameLength = minFramePrefix + payloadLen
|
||||||
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
|
||||||
|
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||||
|
buffer.removeAll()
|
||||||
|
reset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer.count < frameLength {
|
||||||
|
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||||
|
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||||
|
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||||
|
if dropCount > 0 {
|
||||||
|
buffer.removeFirst(dropCount)
|
||||||
|
dropped.append(1) // treat as dropped partial start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
|
frames.append(frame)
|
||||||
|
buffer.removeFirst(frameLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (frames, dropped, reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,103 +54,27 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
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: String) {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,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) {
|
||||||
@@ -222,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.id ?? "",
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ struct RelayController {
|
|||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
if ttlCap <= 1 || senderIsSelf {
|
||||||
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
|
}
|
||||||
|
|
||||||
// For session-critical or directed traffic, be deterministic and reliable
|
// For session-critical or directed traffic, be deterministic and reliable
|
||||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||||
// Always relay with no TTL cap for these types
|
// Always relay with no TTL cap for these types
|
||||||
let newTTL = (ttl &- 1)
|
let newTTL = ttlCap &- 1
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
// Slight jitter to desynchronize without adding too much latency
|
||||||
// Tighter for faster multi-hop handshakes and directed DMs
|
// Tighter for faster multi-hop handshakes and directed DMs
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||||
@@ -32,28 +36,17 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
|
||||||
let baseProb: Double
|
|
||||||
switch degree {
|
|
||||||
case 0...2: baseProb = 1.0
|
|
||||||
case 3...4: baseProb = 0.9
|
|
||||||
case 5...6: baseProb = 0.7
|
|
||||||
case 7...9: baseProb = 0.55
|
|
||||||
default: baseProb = 0.45
|
|
||||||
}
|
|
||||||
let prob = baseProb
|
|
||||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep very low to avoid floods
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
// - Announces get a bit more headroom
|
||||||
// - Announces in sparse graphs get a bit more headroom
|
let ttlLimit: UInt8 = {
|
||||||
let ttlCap: UInt8 = {
|
if degree >= highDegreeThreshold {
|
||||||
if degree >= highDegreeThreshold { return 3 }
|
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||||
return isAnnounce ? 7 : 6
|
}
|
||||||
|
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||||
|
return max(UInt8(2), min(ttlCap, preferred))
|
||||||
}()
|
}()
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let newTTL = ttlLimit &- 1
|
||||||
let newTTL = clamped &- 1
|
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||||
@@ -64,6 +57,6 @@ struct RelayController {
|
|||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
case 6...9: delayMs = Int.random(in: 80...180)
|
||||||
default: delayMs = Int.random(in: 100...220)
|
default: delayMs = Int.random(in: 100...220)
|
||||||
}
|
}
|
||||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
|
||||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
|
||||||
}
|
|
||||||
@@ -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,33 @@ 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 sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
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)
|
||||||
|
|
||||||
// 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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ enum TransportConfig {
|
|||||||
// Location
|
// Location
|
||||||
static let locationDistanceFilterMeters: Double = 1000
|
static let locationDistanceFilterMeters: Double = 1000
|
||||||
// Live (channel sheet open) distance threshold for meaningful updates
|
// Live (channel sheet open) distance threshold for meaningful updates
|
||||||
static let locationDistanceFilterLiveMeters: Double = 21.0
|
static let locationDistanceFilterLiveMeters: Double = 10.0
|
||||||
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
||||||
|
|
||||||
// Notifications (geohash)
|
// Notifications (geohash)
|
||||||
@@ -188,7 +188,7 @@ enum TransportConfig {
|
|||||||
static let uiWindowStepCount: Int = 200
|
static let uiWindowStepCount: Int = 200
|
||||||
|
|
||||||
// Share extension
|
// Share extension
|
||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
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
|
||||||
@@ -18,23 +18,25 @@ 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 identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(meshService: Transport) {
|
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
setupSubscriptions()
|
setupSubscriptions()
|
||||||
@@ -75,12 +77,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(
|
||||||
@@ -101,7 +103,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 }
|
||||||
@@ -135,10 +137,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)
|
||||||
@@ -174,7 +176,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Determine reachability based on lastSeen and identity trust
|
// Determine reachability based on lastSeen and identity trust
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
||||||
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
|
||||||
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
||||||
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
||||||
@@ -182,7 +184,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,
|
||||||
@@ -195,27 +197,6 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favoriteStatus = favorites[noiseKey] {
|
let favoriteStatus = favorites[noiseKey] {
|
||||||
peer.favoriteStatus = favoriteStatus
|
peer.favoriteStatus = favoriteStatus
|
||||||
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
||||||
} else {
|
|
||||||
// Check by nickname for reconnected peers
|
|
||||||
let favoriteByNickname = favorites.values.first {
|
|
||||||
$0.peerNickname == peerInfo.nickname
|
|
||||||
}
|
|
||||||
|
|
||||||
if let favorite = favoriteByNickname,
|
|
||||||
let noiseKey = peerInfo.noisePublicKey {
|
|
||||||
SecureLogger.debug("🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key", category: .session)
|
|
||||||
|
|
||||||
// Update the favorite's key in persistence
|
|
||||||
favoritesService.updateNoisePublicKey(
|
|
||||||
from: favorite.peerNoisePublicKey,
|
|
||||||
to: noiseKey,
|
|
||||||
peerNickname: peerInfo.nickname
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get updated favorite
|
|
||||||
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
|
||||||
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return peer
|
return peer
|
||||||
@@ -223,10 +204,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,
|
||||||
@@ -243,32 +224,27 @@ 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) -> String? {
|
||||||
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.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 }
|
||||||
|
|
||||||
// Check SecureIdentityStateManager for block status
|
// Check SecureIdentityStateManager for block status
|
||||||
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
if let identity = identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
return identity.isBlocked
|
return identity.isBlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +252,7 @@ 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
|
||||||
@@ -340,39 +316,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 = SecureIdentityStateManager.shared.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureIdentityStateManager.shared.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
|
||||||
@@ -397,23 +341,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: [PeerID] { Array(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
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// Golomb-Coded Set (GCS) filter utilities for sync.
|
||||||
|
// Hashing:
|
||||||
|
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
||||||
|
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
|
||||||
|
// Encoding (v1):
|
||||||
|
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
||||||
|
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
||||||
|
// - Bitstream is MSB-first within each byte.
|
||||||
|
enum GCSFilter {
|
||||||
|
struct Params { let p: Int; let m: UInt32; let data: Data }
|
||||||
|
|
||||||
|
// Derive P from FPR (~ 1 / 2^P)
|
||||||
|
static func deriveP(targetFpr: Double) -> Int {
|
||||||
|
let f = max(0.000001, min(0.25, targetFpr))
|
||||||
|
// ceil(log2(1/f))
|
||||||
|
let p = Int(ceil(log2(1.0 / f)))
|
||||||
|
return max(1, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)
|
||||||
|
static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {
|
||||||
|
let bits = max(8, sizeBytes * 8)
|
||||||
|
let per = max(3, p + 2)
|
||||||
|
return max(1, bits / per)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||||
|
let p = deriveP(targetFpr: targetFpr)
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
return Params(p: p, m: 1, data: Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||||
|
let selected = Array(ids.prefix(cap))
|
||||||
|
let range = max(1, hashRange(count: selected.count, p: p))
|
||||||
|
let modulo = UInt64(range)
|
||||||
|
|
||||||
|
var mapped = selected
|
||||||
|
.map { h64($0) }
|
||||||
|
.map { mapHash($0, modulo: modulo) }
|
||||||
|
.sorted()
|
||||||
|
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||||
|
|
||||||
|
if mapped.isEmpty {
|
||||||
|
return Params(p: p, m: range, data: Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = encode(sorted: mapped, p: p)
|
||||||
|
var trimmedCount = mapped.count
|
||||||
|
|
||||||
|
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||||
|
if trimmedCount == 1 {
|
||||||
|
mapped.removeAll()
|
||||||
|
encoded = Data()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||||
|
mapped = Array(mapped.prefix(trimmedCount))
|
||||||
|
encoded = encode(sorted: mapped, p: p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Params(p: p, m: range, data: encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||||
|
var values: [UInt64] = []
|
||||||
|
let reader = BitReader(data)
|
||||||
|
var acc: UInt64 = 0
|
||||||
|
while true {
|
||||||
|
guard let q = reader.readUnary() else { break }
|
||||||
|
guard let r = reader.readBits(count: p) else { break }
|
||||||
|
let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1
|
||||||
|
acc &+= x
|
||||||
|
if acc >= UInt64(m) { break }
|
||||||
|
values.append(acc)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {
|
||||||
|
var lo = 0
|
||||||
|
var hi = sortedValues.count - 1
|
||||||
|
while lo <= hi {
|
||||||
|
let mid = (lo + hi) >> 1
|
||||||
|
let v = sortedValues[mid]
|
||||||
|
if v == candidate { return true }
|
||||||
|
if v < candidate { lo = mid + 1 } else { hi = mid - 1 }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
|
||||||
|
let modulo = UInt64(max(1, m))
|
||||||
|
guard modulo > 1 else { return 0 }
|
||||||
|
return mapHash(h64(id), modulo: modulo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func h64(_ id16: Data) -> UInt64 {
|
||||||
|
var hasher = SHA256()
|
||||||
|
hasher.update(data: id16)
|
||||||
|
let d = hasher.finalize()
|
||||||
|
let db = Data(d)
|
||||||
|
var x: UInt64 = 0
|
||||||
|
let take = min(8, db.count)
|
||||||
|
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
||||||
|
return x & 0x7fff_ffff_ffff_ffff
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hashRange(count: Int, p: Int) -> UInt32 {
|
||||||
|
guard count > 0 else { return 1 }
|
||||||
|
if p >= 64 { return UInt32.max }
|
||||||
|
let multiplier = UInt64(1) << UInt64(p)
|
||||||
|
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
|
||||||
|
if overflow { return UInt32.max }
|
||||||
|
if product == 0 { return 1 }
|
||||||
|
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
|
||||||
|
guard modulo > 1 else { return 0 }
|
||||||
|
let value = hash % modulo
|
||||||
|
if value == 0 { return 1 }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
|
||||||
|
guard modulo > 1 else { return [] }
|
||||||
|
guard !values.isEmpty else { return [] }
|
||||||
|
var result: [UInt64] = []
|
||||||
|
result.reserveCapacity(values.count)
|
||||||
|
var last: UInt64 = 0
|
||||||
|
for value in values {
|
||||||
|
let normalized = min(value, modulo - 1)
|
||||||
|
if normalized > last {
|
||||||
|
result.append(normalized)
|
||||||
|
last = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
||||||
|
let writer = BitWriter()
|
||||||
|
var prev: UInt64 = 0
|
||||||
|
let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)
|
||||||
|
for v in sorted {
|
||||||
|
let delta = v &- prev
|
||||||
|
prev = v
|
||||||
|
let x = delta
|
||||||
|
let q = (x &- 1) >> UInt64(p)
|
||||||
|
let r = (x &- 1) & mask
|
||||||
|
// unary q ones then zero
|
||||||
|
if q > 0 { writer.writeOnes(count: Int(q)) }
|
||||||
|
writer.writeBit(0)
|
||||||
|
writer.writeBits(value: r, count: p)
|
||||||
|
}
|
||||||
|
return writer.toData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bit helpers (MSB-first)
|
||||||
|
private final class BitWriter {
|
||||||
|
private var buf = Data()
|
||||||
|
private var cur: UInt8 = 0
|
||||||
|
private var nbits: Int = 0
|
||||||
|
func writeBit(_ bit: Int) { // 0 or 1
|
||||||
|
cur = UInt8((Int(cur) << 1) | (bit & 1))
|
||||||
|
nbits += 1
|
||||||
|
if nbits == 8 {
|
||||||
|
buf.append(cur)
|
||||||
|
cur = 0; nbits = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func writeOnes(count: Int) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
for _ in 0..<count { writeBit(1) }
|
||||||
|
}
|
||||||
|
func writeBits(value: UInt64, count: Int) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
for i in stride(from: count - 1, through: 0, by: -1) {
|
||||||
|
let bit = Int((value >> UInt64(i)) & 1)
|
||||||
|
writeBit(bit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func toData() -> Data {
|
||||||
|
if nbits > 0 {
|
||||||
|
let rem = UInt8(Int(cur) << (8 - nbits))
|
||||||
|
buf.append(rem)
|
||||||
|
cur = 0; nbits = 0
|
||||||
|
}
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class BitReader {
|
||||||
|
private let data: Data
|
||||||
|
private var idx: Int = 0
|
||||||
|
private var cur: UInt8 = 0
|
||||||
|
private var left: Int = 0
|
||||||
|
init(_ data: Data) {
|
||||||
|
self.data = data
|
||||||
|
if !data.isEmpty {
|
||||||
|
cur = data[0]
|
||||||
|
left = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func readBit() -> Int? {
|
||||||
|
if idx >= data.count { return nil }
|
||||||
|
let bit = (Int(cur) >> 7) & 1
|
||||||
|
cur = UInt8((Int(cur) << 1) & 0xFF)
|
||||||
|
left -= 1
|
||||||
|
if left == 0 {
|
||||||
|
idx += 1
|
||||||
|
if idx < data.count { cur = data[idx]; left = 8 }
|
||||||
|
}
|
||||||
|
return bit
|
||||||
|
}
|
||||||
|
func readUnary() -> Int? {
|
||||||
|
var q = 0
|
||||||
|
while true {
|
||||||
|
guard let b = readBit() else { return nil }
|
||||||
|
if b == 1 { q += 1 } else { break }
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
func readBits(count: Int) -> UInt64? {
|
||||||
|
var v: UInt64 = 0
|
||||||
|
for _ in 0..<count {
|
||||||
|
guard let b = readBit() else { return nil }
|
||||||
|
v = (v << 1) | UInt64(b)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
|
final class GossipSyncManager {
|
||||||
|
protocol Delegate: AnyObject {
|
||||||
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private let myPeerID: PeerID
|
||||||
|
private let config: Config
|
||||||
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
|
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
||||||
|
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
||||||
|
private var messageOrder: [String] = []
|
||||||
|
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
|
||||||
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
|
self.myPeerID = myPeerID
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
stop()
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
|
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.cleanupExpiredMessages()
|
||||||
|
self?.sendRequestSync()
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
periodicTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
periodicTimer?.cancel(); periodicTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
|
self?.sendRequestSync(to: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._onPublicPacketSeen(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
|
let mt = MessageType(rawValue: packet.type)
|
||||||
|
let isBroadcastRecipient: Bool = {
|
||||||
|
guard let r = packet.recipientID else { return true }
|
||||||
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
||||||
|
let isAnnounce = (mt == .announce)
|
||||||
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
|
if isBroadcastMessage {
|
||||||
|
if messages[idHex] == nil {
|
||||||
|
messages[idHex] = packet
|
||||||
|
messageOrder.append(idHex)
|
||||||
|
// Enforce capacity
|
||||||
|
let cap = max(1, config.seenCapacity)
|
||||||
|
while messageOrder.count > cap {
|
||||||
|
let victim = messageOrder.removeFirst()
|
||||||
|
messages.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if isAnnounce {
|
||||||
|
let sender = packet.senderID.hexEncodedString()
|
||||||
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRequestSync() {
|
||||||
|
let payload = buildGcsPayload()
|
||||||
|
let pkt = BitchatPacket(
|
||||||
|
type: MessageType.requestSync.rawValue,
|
||||||
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
|
recipientID: nil, // broadcast
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 0 // local-only
|
||||||
|
)
|
||||||
|
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
|
||||||
|
delegate?.sendPacket(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendRequestSync(to peerID: PeerID) {
|
||||||
|
let payload = buildGcsPayload()
|
||||||
|
var recipient = Data()
|
||||||
|
var temp = peerID.id
|
||||||
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
|
let hexByte = String(temp.prefix(2))
|
||||||
|
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||||
|
temp = String(temp.dropFirst(2))
|
||||||
|
}
|
||||||
|
let pkt = BitchatPacket(
|
||||||
|
type: MessageType.requestSync.rawValue,
|
||||||
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
|
recipientID: recipient,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 0 // local-only
|
||||||
|
)
|
||||||
|
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
|
||||||
|
delegate?.sendPacket(to: peerID, packet: signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._handleRequestSync(from: peerID, request: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
|
func mightContain(_ id: Data) -> Bool {
|
||||||
|
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
|
||||||
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||||
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
|
let (idHex, pkt) = pair
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Broadcast messages: send all missing (and not expired)
|
||||||
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
|
for pkt in toSendMsgs {
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
|
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
|
||||||
|
private func buildGcsPayload() -> Data {
|
||||||
|
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||||
|
var candidates: [BitchatPacket] = []
|
||||||
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
|
if isPacketFresh(pair.packet) {
|
||||||
|
candidates.append(pair.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in messageOrder {
|
||||||
|
if let p = messages[id], isPacketFresh(p) {
|
||||||
|
candidates.append(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort by timestamp desc
|
||||||
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
|
let cap = max(1, config.seenCapacity)
|
||||||
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
|
if takeN <= 0 {
|
||||||
|
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
||||||
|
return req.encode()
|
||||||
|
}
|
||||||
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
||||||
|
return req.encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of expired messages and announcements
|
||||||
|
private func cleanupExpiredMessages() {
|
||||||
|
// Remove expired announcements
|
||||||
|
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired messages
|
||||||
|
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||||
|
isPacketFresh(pkt) ? nil : id
|
||||||
|
}
|
||||||
|
for id in expiredMessageIds {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove messages and update messageOrder
|
||||||
|
for id in messageIdsToRemove {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// Deterministic packet ID used for gossip sync membership
|
||||||
|
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
|
||||||
|
enum PacketIdUtil {
|
||||||
|
static func computeId(_ packet: BitchatPacket) -> Data {
|
||||||
|
var hasher = SHA256()
|
||||||
|
hasher.update(data: Data([packet.type]))
|
||||||
|
hasher.update(data: packet.senderID)
|
||||||
|
var tsBE = packet.timestamp.bigEndian
|
||||||
|
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
|
||||||
|
hasher.update(data: packet.payload)
|
||||||
|
let digest = hasher.finalize()
|
||||||
|
return Data(digest.prefix(16))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// Color+Peer.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
private static var peerColorCache: [String: Color] = [:]
|
||||||
|
|
||||||
|
init(peerSeed: String, isDark: Bool) {
|
||||||
|
let cacheKey = peerSeed + (isDark ? "|dark" : "|light")
|
||||||
|
if let cached = Self.peerColorCache[cacheKey] {
|
||||||
|
self = cached
|
||||||
|
}
|
||||||
|
let h = peerSeed.djb2()
|
||||||
|
var hue = Double(h % 1000) / 1000.0
|
||||||
|
let orange = 30.0 / 360.0
|
||||||
|
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
|
||||||
|
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
|
||||||
|
}
|
||||||
|
let sRand = Double((h >> 17) & 0x3FF) / 1023.0
|
||||||
|
let bRand = Double((h >> 27) & 0x3FF) / 1023.0
|
||||||
|
let sBase: Double = isDark ? 0.80 : 0.70
|
||||||
|
let sRange: Double = 0.20
|
||||||
|
let bBase: Double = isDark ? 0.75 : 0.45
|
||||||
|
let bRange: Double = isDark ? 0.16 : 0.14
|
||||||
|
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
|
||||||
|
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
|
||||||
|
let c = Color(hue: hue, saturation: saturation, brightness: brightness)
|
||||||
|
Self.peerColorCache[cacheKey] = c
|
||||||
|
self = c
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
|
||||||
|
/// preferred text styles so the UI scales with user accessibility settings.
|
||||||
|
extension Font {
|
||||||
|
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
|
||||||
|
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
|
||||||
|
var font = Font.system(style, design: design)
|
||||||
|
if weight != .regular {
|
||||||
|
font = font.weight(weight)
|
||||||
|
}
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Font.TextStyle {
|
||||||
|
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
|
||||||
|
switch size {
|
||||||
|
case ..<11.5:
|
||||||
|
return .caption2
|
||||||
|
case ..<13.0:
|
||||||
|
return .caption
|
||||||
|
case ..<13.75:
|
||||||
|
return .footnote
|
||||||
|
case ..<15.5:
|
||||||
|
return .subheadline
|
||||||
|
case ..<17.5:
|
||||||
|
return .callout
|
||||||
|
case ..<19.5:
|
||||||
|
return .body
|
||||||
|
case ..<22.5:
|
||||||
|
return .title3
|
||||||
|
case ..<27.5:
|
||||||
|
return .title2
|
||||||
|
case ..<34.0:
|
||||||
|
return .title
|
||||||
|
default:
|
||||||
|
return .largeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,35 +8,15 @@ struct InputValidator {
|
|||||||
|
|
||||||
struct Limits {
|
struct Limits {
|
||||||
static let maxNicknameLength = 50
|
static let maxNicknameLength = 50
|
||||||
static let maxMessageLength = 10_000
|
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||||
static let maxReasonLength = 200
|
// for headers/padding by limiting user content to 60_000 bytes.
|
||||||
static let maxPeerIDLength = 64
|
static let maxMessageLength = 60_000
|
||||||
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? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
// Check empty
|
// Check empty
|
||||||
guard !string.isEmpty else { return nil }
|
guard !string.isEmpty else { return nil }
|
||||||
|
|
||||||
@@ -47,13 +27,8 @@ struct InputValidator {
|
|||||||
// Check length
|
// Check length
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Remove control characters except allowed ones
|
// Remove control characters
|
||||||
var allowedControlChars = CharacterSet()
|
let controlChars = CharacterSet.controlCharacters
|
||||||
if allowNewlines {
|
|
||||||
allowedControlChars.insert(charactersIn: "\n\r")
|
|
||||||
}
|
|
||||||
|
|
||||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||||
|
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
// Ensure valid UTF-8 (should already be, but double-check)
|
||||||
@@ -68,17 +43,7 @@ struct InputValidator {
|
|||||||
|
|
||||||
/// 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
|
||||||
@@ -86,11 +51,6 @@ struct InputValidator {
|
|||||||
// 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()
|
||||||
@@ -99,37 +59,4 @@ struct InputValidator {
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// String+DJB2.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
func djb2() -> UInt64 {
|
||||||
|
var hash: UInt64 = 5381
|
||||||
|
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// String+Nickname.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// Split a nickname into base and a '#abcd' suffix if present
|
||||||
|
func splitSuffix() -> (String, String) {
|
||||||
|
let name = self.replacingOccurrences(of: "@", with: "")
|
||||||
|
guard name.count >= 5 else { return (name, "") }
|
||||||
|
let suffix = String(name.suffix(5))
|
||||||
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||||
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||||
|
}) {
|
||||||
|
let base = String(name.dropLast(5))
|
||||||
|
return (base, suffix)
|
||||||
|
}
|
||||||
|
return (name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1063
-981
File diff suppressed because it is too large
Load Diff
+127
-91
@@ -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)
|
||||||
@@ -86,10 +122,14 @@ struct AppInfoView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("close") {
|
Button(action: { dismiss() }) {
|
||||||
dismiss()
|
Image(systemName: "xmark")
|
||||||
}
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("app_info.close")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,82 +142,64 @@ struct AppInfoView: View {
|
|||||||
// Header
|
// Header
|
||||||
VStack(alignment: .center, spacing: 8) {
|
VStack(alignment: .center, spacing: 8) {
|
||||||
Text(Strings.appName)
|
Text(Strings.appName)
|
||||||
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(Strings.tagline)
|
Text(Strings.tagline)
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
|
|
||||||
// Features
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
SectionHeader(Strings.Features.title)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
|
||||||
title: Strings.Features.offlineComm.1,
|
|
||||||
description: Strings.Features.offlineComm.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.encryption.0,
|
|
||||||
title: Strings.Features.encryption.1,
|
|
||||||
description: Strings.Features.encryption.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
|
||||||
title: Strings.Features.extendedRange.1,
|
|
||||||
description: Strings.Features.extendedRange.2)
|
|
||||||
|
|
||||||
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
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
SectionHeader(Strings.Privacy.title)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
|
||||||
title: Strings.Privacy.noTracking.1,
|
|
||||||
description: Strings.Privacy.noTracking.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// How to Use
|
// How to Use
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Features
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
SectionHeader(Strings.Features.title)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.offlineComm)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.encryption)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.extendedRange)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.favorites)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.geohash)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.mentions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Privacy
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
SectionHeader(Strings.Privacy.title)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Privacy.noTracking)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
|
}
|
||||||
|
|
||||||
// Warning
|
// Warning
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
SectionHeader(Strings.Warning.title)
|
SectionHeader(Strings.Warning.title)
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
|
|
||||||
Text(Strings.Warning.message)
|
Text(Strings.Warning.message)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -193,30 +215,34 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -229,18 +255,18 @@ 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(.system(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(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(description)
|
Text(info.description)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -250,6 +276,16 @@ struct FeatureRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview("Default") {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Dynamic Type XXL") {
|
||||||
|
AppInfoView()
|
||||||
|
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Dynamic Type XS") {
|
||||||
|
AppInfoView()
|
||||||
|
.environment(\.sizeCategory, .extraSmall)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// PaymentChipView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PaymentChipView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
enum PaymentType {
|
||||||
|
case cashu(String)
|
||||||
|
case lightning(String)
|
||||||
|
|
||||||
|
var url: URL? {
|
||||||
|
switch self {
|
||||||
|
case .cashu(let link), .lightning(let link):
|
||||||
|
return URL(string: link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var emoji: String {
|
||||||
|
switch self {
|
||||||
|
case .cashu: "🥜"
|
||||||
|
case .lightning: "⚡"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .cashu:
|
||||||
|
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||||
|
case .lightning:
|
||||||
|
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paymentType: PaymentType
|
||||||
|
|
||||||
|
private var fgColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
}
|
||||||
|
private var bgColor: Color {
|
||||||
|
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||||
|
}
|
||||||
|
private var border: Color { fgColor.opacity(0.25) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
#if os(iOS)
|
||||||
|
if let url = paymentType.url { openURL(url) }
|
||||||
|
#else
|
||||||
|
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(paymentType.emoji)
|
||||||
|
Text(paymentType.label)
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(bgColor)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundColor(fgColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let cashuLink = "https://example.com/cashu"
|
||||||
|
let lightningLink = "https://example.com/lightning"
|
||||||
|
|
||||||
|
List {
|
||||||
|
HStack {
|
||||||
|
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||||
|
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
|
List {
|
||||||
|
HStack {
|
||||||
|
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||||
|
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// TextMessageView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TextMessageView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
|
@EnvironmentObject private var viewModel: ChatViewModel
|
||||||
|
|
||||||
|
let message: BitchatMessage
|
||||||
|
@Binding var expandedMessageIDs: Set<String>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Precompute heavy token scans once per row
|
||||||
|
let cashuLinks = message.content.extractCashuLinks()
|
||||||
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Delivery status indicator for private messages
|
||||||
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
|
let status = message.deliveryStatus {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand/Collapse for very long messages
|
||||||
|
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
|
Button(labelKey) {
|
||||||
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(Color.blue)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render payment chips (Lightning / Cashu) with rounded background
|
||||||
|
if !lightningLinks.isEmpty || !cashuLinks.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(lightningLinks, id: \.self) { link in
|
||||||
|
PaymentChipView(paymentType: .lightning(link))
|
||||||
|
}
|
||||||
|
ForEach(cashuLinks, id: \.self) { link in
|
||||||
|
PaymentChipView(paymentType: .cashu(link))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(macOS 14, iOS 17, *)
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var ids: Set<String> = []
|
||||||
|
let keychain = PreviewKeychainManager()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
List {
|
||||||
|
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
|
List {
|
||||||
|
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
.environmentObject(
|
||||||
|
ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
+451
-377
File diff suppressed because it is too large
Load Diff
@@ -22,19 +22,42 @@ struct FingerprintView: View {
|
|||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let title: LocalizedStringKey = "fingerprint.title"
|
||||||
|
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
|
||||||
|
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
|
||||||
|
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
|
||||||
|
static let copy: LocalizedStringKey = "common.copy"
|
||||||
|
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
|
||||||
|
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||||
|
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||||
|
static func verifyHint(_ nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||||
|
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||||
|
static func unknownPeer() -> String {
|
||||||
|
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
Text("SECURITY VERIFICATION")
|
Text(Strings.title)
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
@@ -49,16 +72,16 @@ struct FingerprintView: View {
|
|||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
@@ -66,17 +89,17 @@ struct FingerprintView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(peerNickname)
|
Text(peerNickname)
|
||||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(encryptionStatus.description)
|
Text(encryptionStatus.description)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +111,13 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
// Their fingerprint
|
// Their fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("THEIR FINGERPRINT:")
|
Text(Strings.theirFingerprint)
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -104,7 +127,7 @@ struct FingerprintView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy") {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = fingerprint
|
UIPasteboard.general.string = fingerprint
|
||||||
#else
|
#else
|
||||||
@@ -114,8 +137,8 @@ struct FingerprintView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("not available - handshake in progress")
|
Text(Strings.handshakePending)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -123,13 +146,13 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
// My fingerprint
|
// My fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("YOUR FINGERPRINT:")
|
Text(Strings.yourFingerprint)
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
let myFingerprint = viewModel.getMyFingerprint()
|
let myFingerprint = viewModel.getMyFingerprint()
|
||||||
Text(formatFingerprint(myFingerprint))
|
Text(formatFingerprint(myFingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -139,7 +162,7 @@ struct FingerprintView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy") {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = myFingerprint
|
UIPasteboard.general.string = myFingerprint
|
||||||
#else
|
#else
|
||||||
@@ -155,15 +178,19 @@ struct FingerprintView: View {
|
|||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
.foregroundColor(isVerified ? Color.green : Color.orange)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Text(isVerified ?
|
Group {
|
||||||
"you have verified this person's identity." :
|
if isVerified {
|
||||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
Text(Strings.verifiedMessage)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
} else {
|
||||||
|
Text(Strings.verifyHint(peerNickname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -175,8 +202,8 @@ struct FingerprintView: View {
|
|||||||
viewModel.verifyFingerprint(for: peerID)
|
viewModel.verifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("MARK AS VERIFIED")
|
Text(Strings.markVerified)
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -189,8 +216,8 @@ struct FingerprintView: View {
|
|||||||
viewModel.unverifyFingerprint(for: peerID)
|
viewModel.unverifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("REMOVE VERIFICATION")
|
Text(Strings.removeVerification)
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ struct GeohashPeopleList: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||||
|
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||||
|
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||||
|
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||||
|
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if viewModel.visibleGeohashPeople().isEmpty {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text(Strings.noneNearby)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -51,32 +59,32 @@ struct GeohashPeopleList: View {
|
|||||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||||
let rowColor: Color = isMe ? .orange : assignedColor
|
let rowColor: Color = isMe ? .orange : assignedColor
|
||||||
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
|
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||||
|
|
||||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
let (base, suffix) = person.displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(isMe ? .bold : .regular)
|
.fontWeight(isMe ? .bold : .regular)
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
if !suffix.isEmpty {
|
if !suffix.isEmpty {
|
||||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
if isMe {
|
if isMe {
|
||||||
Text(" (you)")
|
Text(Strings.youSuffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let me = myHex, person.id != me {
|
if let me = myHex, person.id != me {
|
||||||
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked in geochash")
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -97,9 +105,9 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
if blocked {
|
if blocked {
|
||||||
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
} else {
|
} else {
|
||||||
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,16 +126,3 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to split a trailing #abcd suffix
|
|
||||||
private func splitSuffix(from name: String) -> (String, String) {
|
|
||||||
guard name.count >= 5 else { return (name, "") }
|
|
||||||
let suffix = String(name.suffix(5))
|
|
||||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
|
||||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
|
||||||
}) {
|
|
||||||
let base = String(name.dropLast(5))
|
|
||||||
return (base, suffix)
|
|
||||||
}
|
|
||||||
return (name, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,26 +9,89 @@ struct LocationChannelsSheet: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@ObservedObject private var manager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
|
@ObservedObject private var network = NetworkActivationService.shared
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var customGeohash: String = ""
|
@State private var customGeohash: String = ""
|
||||||
@State private var customError: String? = nil
|
@State private var customError: String? = nil
|
||||||
|
|
||||||
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let title: LocalizedStringKey = "location_channels.title"
|
||||||
|
static let description: LocalizedStringKey = "location_channels.description"
|
||||||
|
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
|
||||||
|
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
|
||||||
|
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
|
||||||
|
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
|
||||||
|
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||||||
|
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||||||
|
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||||
|
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||||
|
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||||
|
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||||
|
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||||
|
|
||||||
|
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||||
|
|
||||||
|
static func meshTitle(_ count: Int) -> String {
|
||||||
|
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||||
|
return rowTitle(label: label, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||||
|
return rowTitle(label: level.displayName, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||||
|
return rowTitle(label: "#\(geohash)", count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||||
|
locale: .current,
|
||||||
|
geohash, coverage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func subtitle(prefix: String, name: String?) -> String {
|
||||||
|
guard let name, !name.isEmpty else { return prefix }
|
||||||
|
return String(
|
||||||
|
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||||
|
locale: .current,
|
||||||
|
prefix, name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rowTitle(label: String, count: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||||
|
locale: .current,
|
||||||
|
label, count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("#location channels")
|
HStack(spacing: 12) {
|
||||||
.font(.system(size: 18, design: .monospaced))
|
Text(Strings.title)
|
||||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.")
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
.font(.system(size: 12, design: .monospaced))
|
Spacer()
|
||||||
|
closeButton
|
||||||
|
}
|
||||||
|
Text(Strings.description)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
switch manager.permissionState {
|
switch manager.permissionState {
|
||||||
case LocationChannelManager.PermissionState.notDetermined:
|
case LocationChannelManager.PermissionState.notDetermined:
|
||||||
Button(action: { manager.enableLocationChannels() }) {
|
Button(action: { manager.enableLocationChannels() }) {
|
||||||
Text("get location and my geohashes")
|
Text(Strings.requestPermissions)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -38,10 +101,10 @@ struct LocationChannelsSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("location permission denied. enable in settings to use location channels.")
|
Text(Strings.permissionDenied)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("open settings") { openSystemLocationSettings() }
|
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -54,21 +117,12 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
.background(backgroundColor)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.navigationBarHidden(true)
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("close") { isPresented = false }
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
.toolbar {
|
.navigationTitle("")
|
||||||
ToolbarItem(placement: .automatic) {
|
|
||||||
Button("close") { isPresented = false }
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -77,6 +131,7 @@ struct LocationChannelsSheet: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
|
.background(backgroundColor)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Refresh channels when opening
|
// Refresh channels when opening
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
@@ -97,24 +152,37 @@ struct LocationChannelsSheet: View {
|
|||||||
.onChange(of: manager.availableChannels) { _ in }
|
.onChange(of: manager.availableChannels) { _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var closeButton: some View {
|
||||||
|
Button(action: { isPresented = false }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Close")
|
||||||
|
}
|
||||||
|
|
||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
List {
|
ScrollView {
|
||||||
// Mesh option first (no bookmark)
|
LazyVStack(spacing: 0) {
|
||||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
// Nearby options
|
let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||||
if !manager.availableChannels.isEmpty {
|
if !nearby.isEmpty {
|
||||||
ForEach(manager.availableChannels) { channel in
|
ForEach(nearby) { channel in
|
||||||
|
sectionDivider
|
||||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||||
let nameBase = locationName(for: channel.level)
|
let nameBase = locationName(for: channel.level)
|
||||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||||||
|
let highlight = participantCount > 0
|
||||||
channelRow(
|
channelRow(
|
||||||
title: geohashTitleWithCount(for: channel),
|
title: Strings.levelTitle(for: channel.level, count: participantCount),
|
||||||
subtitlePrefix: subtitlePrefix,
|
subtitlePrefix: subtitlePrefix,
|
||||||
subtitleName: namePart,
|
subtitleName: namePart,
|
||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
@@ -122,31 +190,81 @@ struct LocationChannelsSheet: View {
|
|||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
|
||||||
manager.markTeleported(for: channel.geohash, false)
|
manager.markTeleported(for: channel.geohash, false)
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
sectionDivider
|
||||||
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("finding nearby channels…")
|
Text(Strings.loadingNearby)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom geohash teleport
|
sectionDivider
|
||||||
|
customTeleportSection
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
let bookmarkedList = bookmarks.bookmarks
|
||||||
|
if !bookmarkedList.isEmpty {
|
||||||
|
sectionDivider
|
||||||
|
bookmarkedSection(bookmarkedList)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
|
sectionDivider
|
||||||
|
torToggleSection
|
||||||
|
.padding(.top, 12)
|
||||||
|
Button(action: {
|
||||||
|
openSystemLocationSettings()
|
||||||
|
}) {
|
||||||
|
Text(Strings.removeAccess)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.red.opacity(0.08))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionDivider: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(dividerColor)
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dividerColor: Color {
|
||||||
|
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customTeleportSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Text("#")
|
Text(verbatim: "#")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("geohash", text: $customGeohash)
|
TextField("geohash", text: $customGeohash)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -154,9 +272,8 @@ struct LocationChannelsSheet: View {
|
|||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.keyboardType(.asciiCapable)
|
.keyboardType(.asciiCapable)
|
||||||
#endif
|
#endif
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.onChange(of: customGeohash) { newValue in
|
.onChange(of: customGeohash) { newValue in
|
||||||
// Allow only geohash base32 characters, strip '#', limit length
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
let filtered = newValue
|
let filtered = newValue
|
||||||
.lowercased()
|
.lowercased()
|
||||||
@@ -168,29 +285,31 @@ struct LocationChannelsSheet: View {
|
|||||||
customGeohash = filtered
|
customGeohash = filtered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
let normalized = customGeohash
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: "#", with: "")
|
||||||
let isValid = validateGeohash(normalized)
|
let isValid = validateGeohash(normalized)
|
||||||
Button(action: {
|
Button(action: {
|
||||||
let gh = normalized
|
let gh = normalized
|
||||||
guard isValid else { customError = "invalid geohash"; return }
|
guard isValid else { customError = Strings.invalidGeohash; return }
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
// Mark this selection as a manual teleport
|
|
||||||
manager.markTeleported(for: ch.geohash, true)
|
manager.markTeleported(for: ch.geohash, true)
|
||||||
manager.select(ChannelID.location(ch))
|
manager.select(ChannelID.location(ch))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("teleport")
|
Text(Strings.teleport)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
Image(systemName: "face.dashed")
|
Image(systemName: "face.dashed")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.background(Color.secondary.opacity(0.12))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
.opacity(isValid ? 1.0 : 0.4)
|
.opacity(isValid ? 1.0 : 0.4)
|
||||||
@@ -198,40 +317,39 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
if let err = customError {
|
if let err = customError {
|
||||||
Text(err)
|
Text(err)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmarked geohashes
|
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("bookmarked")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(Strings.bookmarked)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
let channel = GeohashChannel(level: level, geohash: gh)
|
||||||
let coverage = coverageString(forPrecision: gh.count)
|
let coverage = coverageString(forPrecision: gh.count)
|
||||||
let subtitle = "#\(gh) • \(coverage)"
|
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||||||
let name = bookmarks.bookmarkNames[gh]
|
let name = bookmarks.bookmarkNames[gh]
|
||||||
|
let participantCount = viewModel.geohashParticipantCount(for: gh)
|
||||||
channelRow(
|
channelRow(
|
||||||
title: geohashHashTitleWithCount(gh),
|
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||||
subtitlePrefix: subtitle,
|
subtitlePrefix: subtitle,
|
||||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(gh) }) {
|
Button(action: { bookmarks.toggle(gh) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// For bookmarked selection, mark teleported based on regional membership
|
|
||||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||||
if !inRegional && !manager.availableChannels.isEmpty {
|
if !inRegional && !manager.availableChannels.isEmpty {
|
||||||
manager.markTeleported(for: gh, true)
|
manager.markTeleported(for: gh, true)
|
||||||
@@ -241,29 +359,17 @@ struct LocationChannelsSheet: View {
|
|||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
|
|
||||||
|
if index < entries.count - 1 {
|
||||||
|
sectionDivider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer action inside the list
|
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
|
||||||
Button(action: {
|
|
||||||
openSystemLocationSettings()
|
|
||||||
}) {
|
|
||||||
Text("remove location access")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color.red.opacity(0.08))
|
|
||||||
.cornerRadius(6)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
if case .location(let ch) = manager.selectedChannel {
|
if case .location(let ch) = manager.selectedChannel {
|
||||||
@@ -295,31 +401,26 @@ struct LocationChannelsSheet: View {
|
|||||||
let parts = splitTitleAndCount(title)
|
let parts = splitTitleAndCount(title)
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(parts.base)
|
Text(parts.base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(titleBold ? .bold : .regular)
|
.fontWeight(titleBold ? .bold : .regular)
|
||||||
.foregroundColor(titleColor ?? Color.primary)
|
.foregroundColor(titleColor ?? Color.primary)
|
||||||
if let count = parts.countSuffix, !count.isEmpty {
|
if let count = parts.countSuffix, !count.isEmpty {
|
||||||
Text(count)
|
Text(count)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let subtitleFull: String = {
|
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||||
if let name = subtitleName, !name.isEmpty {
|
|
||||||
return subtitlePrefix + " • " + name
|
|
||||||
}
|
|
||||||
return subtitlePrefix
|
|
||||||
}()
|
|
||||||
Text(subtitleFull)
|
Text(subtitleFull)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Text("✔︎")
|
Text(verbatim: "✔︎")
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
trailingAccessory()
|
trailingAccessory()
|
||||||
@@ -338,36 +439,15 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers for counts
|
// MARK: - Helpers for counts
|
||||||
private func meshTitleWithCount() -> String {
|
|
||||||
// Count currently connected mesh peers (excluding self)
|
|
||||||
let meshCount = meshCount()
|
|
||||||
let noun = meshCount == 1 ? "person" : "people"
|
|
||||||
return "mesh [\(meshCount) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func meshCount() -> Int {
|
private func meshCount() -> Int {
|
||||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
|
||||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
|
||||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
|
||||||
let noun = count == 1 ? "person" : "people"
|
|
||||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
|
||||||
// Bookmarked list: show the #geohash as the main label
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
let noun = count == 1 ? "person" : "people"
|
|
||||||
return "#\(geohash) [\(count) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func validateGeohash(_ s: String) -> Bool {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
@@ -381,13 +461,40 @@ struct LocationChannelsSheet: View {
|
|||||||
case 5: return .city
|
case 5: return .city
|
||||||
case 6: return .neighborhood
|
case 6: return .neighborhood
|
||||||
case 7: return .block
|
case 7: return .block
|
||||||
|
case 8: return .building
|
||||||
default: return .block
|
default: return .block
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Standardized Colors
|
// MARK: - TOR Toggle & Standardized Colors
|
||||||
extension LocationChannelsSheet {
|
extension LocationChannelsSheet {
|
||||||
|
private var torToggleBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { network.userTorEnabled },
|
||||||
|
set: { network.setUserTorEnabled($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var torToggleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Toggle(isOn: torToggleBinding) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(Strings.torTitle)
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(Strings.torSubtitle)
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.12))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
private var standardGreen: Color {
|
private var standardGreen: 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)
|
||||||
}
|
}
|
||||||
@@ -396,6 +503,36 @@ extension LocationChannelsSheet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct IRCToggleStyle: ToggleStyle {
|
||||||
|
let accent: Color
|
||||||
|
let onLabel: LocalizedStringKey
|
||||||
|
let offLabel: LocalizedStringKey
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
Button(action: { configuration.isOn.toggle() }) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
configuration.label
|
||||||
|
Spacer()
|
||||||
|
Text(configuration.isOn ? onLabel : offLabel)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coverage helpers
|
// MARK: - Coverage helpers
|
||||||
extension LocationChannelsSheet {
|
extension LocationChannelsSheet {
|
||||||
private func coverageString(forPrecision len: Int) -> String {
|
private func coverageString(forPrecision len: Int) -> String {
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LocationNotesView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
@StateObject private var manager: LocationNotesManager
|
||||||
|
let geohash: String
|
||||||
|
let onNotesCountChanged: ((Int) -> Void)?
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var draft: String = ""
|
||||||
|
|
||||||
|
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
|
||||||
|
let gh = geohash.lowercased()
|
||||||
|
self.geohash = gh
|
||||||
|
self.onNotesCountChanged = onNotesCountChanged
|
||||||
|
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
|
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
|
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||||
|
static let description: LocalizedStringKey = "location_notes.description"
|
||||||
|
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||||
|
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||||
|
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||||
|
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||||
|
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||||
|
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||||
|
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||||
|
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||||
|
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||||
|
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
notesContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.onDisappear { manager.cancel() }
|
||||||
|
.onChange(of: geohash) { newValue in
|
||||||
|
manager.setGeohash(newValue)
|
||||||
|
}
|
||||||
|
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||||
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
|
onNotesCountChanged?(newValue)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
ScrollView {
|
||||||
|
notesContent
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
#else
|
||||||
|
.navigationTitle("")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
#endif
|
||||||
|
.background(backgroundColor)
|
||||||
|
.onDisappear { manager.cancel() }
|
||||||
|
.onChange(of: geohash) { newValue in
|
||||||
|
manager.setGeohash(newValue)
|
||||||
|
}
|
||||||
|
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||||
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
|
onNotesCountChanged?(newValue)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var closeButton: some View {
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(Strings.closeAccessibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
let count = manager.notes.count
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(headerTitle(for: count))
|
||||||
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
closeButton
|
||||||
|
}
|
||||||
|
if let building = locationManager.locationNames[.building], !building.isEmpty {
|
||||||
|
Text(building)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(accentGreen)
|
||||||
|
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
|
||||||
|
Text(block)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(accentGreen)
|
||||||
|
}
|
||||||
|
Text(Strings.description)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if manager.state == .noRelays {
|
||||||
|
Text(Strings.relaysPaused)
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func headerTitle(for count: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||||
|
locale: .current,
|
||||||
|
geohash, count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notesContent: some View {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
|
if manager.state == .noRelays {
|
||||||
|
noRelaysRow
|
||||||
|
} else if manager.state == .loading && !manager.initialLoadComplete {
|
||||||
|
loadingRow
|
||||||
|
} else if manager.notes.isEmpty {
|
||||||
|
emptyRow
|
||||||
|
} else {
|
||||||
|
ForEach(manager.notes) { note in
|
||||||
|
noteRow(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = manager.errorMessage, manager.state != .noRelays {
|
||||||
|
errorRow(message: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
|
||||||
|
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
|
||||||
|
let ts = timestampText(for: note.createdAt)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(verbatim: "@\(baseName)")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
if !ts.isEmpty {
|
||||||
|
Text(ts)
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Text(note.content)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noRelaysRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.noRelaysNearby)
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
Text(Strings.relaysRetryHint)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button(Strings.retry) { manager.refresh() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text(Strings.loadingNotes)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.emptyTitle)
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
Text(Strings.emptySubtitle)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorRow(message: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
Text(message)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Button(Strings.dismissError) { manager.clearError() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputSection: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
Button(action: send) {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.bitchatSystem(size: 20))
|
||||||
|
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!sendButtonEnabled)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.overlay(Divider(), alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send() {
|
||||||
|
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !content.isEmpty else { return }
|
||||||
|
manager.send(content: content, nickname: viewModel.nickname)
|
||||||
|
draft = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sendButtonEnabled: Bool {
|
||||||
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timestamp Formatting
|
||||||
|
private func timestampText(for date: Date) -> String {
|
||||||
|
let now = Date()
|
||||||
|
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||||
|
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||||
|
return rel.isEmpty ? "" : "\(rel) ago"
|
||||||
|
} else {
|
||||||
|
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||||
|
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||||
|
return fmt.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let relativeFormatter: DateComponentsFormatter = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.allowedUnits = [.day, .hour, .minute]
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .abbreviated
|
||||||
|
f.collapsesLargestUnit = true
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let absDateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.setLocalizedDateFormatFromTemplate("MMM d")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let absDateYearFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.setLocalizedDateFormatFromTemplate("MMM d, y")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -11,103 +11,109 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||||
|
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||||
|
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let myPeerID = viewModel.meshService.myPeerID
|
||||||
|
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||||
|
let isMe = peer.peerID == myPeerID
|
||||||
|
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
||||||
|
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
||||||
|
return (peer, isMe, hasUnread, enc)
|
||||||
|
}
|
||||||
|
// Stable visual order without mutating state here
|
||||||
|
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||||
|
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||||
|
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||||
|
mapped.first(where: { $0.peer.peerID.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.allPeers.isEmpty {
|
if viewModel.allPeers.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text(Strings.noneNearby)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let myPeerID = viewModel.meshService.myPeerID
|
|
||||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
|
||||||
let isMe = peer.id == myPeerID
|
|
||||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
|
|
||||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
|
||||||
return (peer, isMe, hasUnread, enc)
|
|
||||||
}
|
|
||||||
// Stable visual order without mutating state here
|
|
||||||
let currentIDs = mapped.map { $0.peer.id }
|
|
||||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
|
||||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
|
||||||
mapped.first(where: { $0.peer.id == id })
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(0..<peers.count, id: \.self) { idx in
|
ForEach(0..<peers.count, id: \.self) { idx in
|
||||||
let item = peers[idx]
|
let item = peers[idx]
|
||||||
let peer = item.peer
|
let peer = item.peer
|
||||||
let isMe = item.isMe
|
let isMe = item.isMe
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isConnected {
|
} else if peer.isConnected {
|
||||||
// Mesh-connected peer: radio icon
|
// Mesh-connected peer: radio icon
|
||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isReachable {
|
} else if peer.isReachable {
|
||||||
// Mesh-reachable (relayed): point.3 icon
|
// Mesh-reachable (relayed): point.3 icon
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isMutualFavorite {
|
} else if peer.isMutualFavorite {
|
||||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
} else {
|
} else {
|
||||||
// Fallback icon for others (dimmed)
|
// Fallback icon for others (dimmed)
|
||||||
Image(systemName: "person")
|
Image(systemName: "person")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||||
let (base, suffix) = splitSuffix(from: displayName)
|
let (base, suffix) = displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
if !suffix.isEmpty {
|
if !suffix.isEmpty {
|
||||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked")
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
if peer.isConnected {
|
if peer.isConnected {
|
||||||
if let icon = item.enc.icon {
|
if let icon = item.enc.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline: prefer showing verified badge from persisted fingerprints
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if let icon = item.enc.icon {
|
} else if let icon = item.enc.icon {
|
||||||
// Fallback to whatever status says (likely lock if we had a past session)
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,15 +124,15 @@ struct MeshPeerList: View {
|
|||||||
// Unread message indicator for this peer
|
// Unread message indicator for this peer
|
||||||
if !isMe, item.hasUnread {
|
if !isMe, item.hasUnread {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.help("New messages")
|
.help(Strings.newMessagesTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -136,16 +142,16 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
.onAppear {
|
.onAppear {
|
||||||
let currentIDs = mapped.map { $0.peer.id }
|
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||||
orderedIDs = currentIDs
|
orderedIDs = currentIDs
|
||||||
}
|
}
|
||||||
.onChange(of: mapped.map { $0.peer.id }) { ids in
|
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
|
||||||
var newOrder = orderedIDs
|
var newOrder = orderedIDs
|
||||||
newOrder.removeAll { !ids.contains($0) }
|
newOrder.removeAll { !ids.contains($0) }
|
||||||
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||||
@@ -154,16 +160,3 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to split a trailing #abcd suffix
|
|
||||||
private func splitSuffix(from name: String) -> (String, String) {
|
|
||||||
guard name.count >= 5 else { return (name, "") }
|
|
||||||
let suffix = String(name.suffix(5))
|
|
||||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
|
||||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
|
||||||
}) {
|
|
||||||
let base = String(name.dropLast(5))
|
|
||||||
return (base, suffix)
|
|
||||||
}
|
|
||||||
return (name, "")
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user