mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:25:19 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||||
@@ -75,3 +75,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.2
|
||||||
|
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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
@@ -22,6 +23,7 @@ let package = Package(
|
|||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.target(name: "TorC"),
|
.target(name: "TorC"),
|
||||||
.target(name: "tor-nolzma")
|
.target(name: "tor-nolzma")
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,25 @@ 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.
|
||||||
|
|||||||
Generated
+224
-855
File diff suppressed because it is too large
Load Diff
@@ -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 |
+1
-1
@@ -3,4 +3,4 @@
|
|||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
"version" : 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-12
@@ -11,6 +11,9 @@ import UserNotifications
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
@StateObject private var chatViewModel: ChatViewModel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@@ -54,8 +57,8 @@ struct BitchatApp: App {
|
|||||||
#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()
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,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
|
||||||
@@ -84,18 +87,22 @@ 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 {
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
didHandleInitialActive = true
|
didHandleInitialActive = true
|
||||||
}
|
}
|
||||||
didEnterBackground = false
|
didEnterBackground = false
|
||||||
Task.detached {
|
if TorManager.shared.isAutoStartAllowed() {
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
Task.detached {
|
||||||
await MainActor.run {
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
await MainActor.run {
|
||||||
TorURLSession.shared.rebuild()
|
// Rebuild proxied sessions to bind to the live Tor after readiness
|
||||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
TorURLSession.shared.rebuild()
|
||||||
NostrRelayManager.shared.resetAllConnections()
|
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
@@ -130,7 +137,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +90,7 @@
|
|||||||
/// - Advanced conflict resolution
|
/// - Advanced conflict resolution
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//
|
||||||
|
// 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: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -209,6 +210,15 @@ final class NoiseRateLimiter {
|
|||||||
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,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -307,6 +308,15 @@ final class NoiseSessionManager {
|
|||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeAllSessions() {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
for (_, session) in sessions {
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
sessions.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
// Note: We can't efficiently delete all noise-nostr associations
|
kSecReturnAttributes as String: true
|
||||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
]
|
||||||
// The important part is deleting the current identity so a new one is generated
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSeedCache = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
@@ -34,10 +35,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)
|
||||||
@@ -64,6 +69,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
|
||||||
@@ -77,27 +84,55 @@ 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() {
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
// Global network policy gate
|
||||||
Task.detached {
|
guard networkService.activationAllowed else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
if shouldUseTor {
|
||||||
await MainActor.run {
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
if !ready {
|
Task.detached {
|
||||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
let ready = await TorManager.shared.awaitReady()
|
||||||
return
|
await MainActor.run {
|
||||||
}
|
if !ready {
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||||
for relay in self.relays {
|
return
|
||||||
self.connectToRelay(relay.url)
|
}
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||||
|
for relay in self.relays {
|
||||||
|
self.connectToRelay(relay.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||||
|
for relay in self.relays {
|
||||||
|
connectToRelay(relay.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +151,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 }
|
||||||
@@ -125,20 +164,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 }
|
||||||
@@ -147,7 +187,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
|
||||||
@@ -212,6 +254,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
handler: @escaping (NostrEvent) -> Void,
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
onEOSE: (() -> Void)? = nil
|
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 {
|
||||||
@@ -220,7 +264,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 }
|
||||||
@@ -248,32 +292,37 @@ 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
|
// Initialize EOSE tracking if requested
|
||||||
if let onEOSE = onEOSE {
|
if let onEOSE = onEOSE {
|
||||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
if urls.isEmpty {
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
onEOSE()
|
||||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
} else {
|
||||||
Task { @MainActor in
|
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||||
guard let self = self else { return }
|
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||||
if let t = self.eoseTrackers[id] {
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
t.timer?.invalidate()
|
Task { @MainActor in
|
||||||
self.eoseTrackers.removeValue(forKey: id)
|
guard let self = self else { return }
|
||||||
onEOSE()
|
if let t = self.eoseTrackers[id] {
|
||||||
|
t.timer?.invalidate()
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
onEOSE()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -288,6 +337,55 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -316,13 +414,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +437,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()
|
||||||
@@ -522,6 +622,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)
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// 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 +70,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 +84,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,187 +128,6 @@ 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
|
||||||
@@ -391,74 +157,6 @@ 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
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
@@ -495,37 +193,3 @@ extension BitchatDelegate {
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
@@ -6,6 +7,78 @@ import CryptoKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// BLEService — Bluetooth Mesh Transport
|
/// BLEService — Bluetooth Mesh Transport
|
||||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||||
@@ -39,6 +112,7 @@ final class BLEService: NSObject {
|
|||||||
var isConnecting: Bool = false
|
var isConnecting: Bool = false
|
||||||
var isConnected: Bool = false
|
var isConnected: Bool = false
|
||||||
var lastConnectionAttempt: Date? = nil
|
var lastConnectionAttempt: Date? = nil
|
||||||
|
var assembler = NotificationStreamAssembler()
|
||||||
}
|
}
|
||||||
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
|
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
|
||||||
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
|
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
|
||||||
@@ -88,8 +162,9 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
var myPeerID: String = ""
|
var myPeerID: String = ""
|
||||||
var myNickname: String = "anon"
|
var myNickname: String = "anon"
|
||||||
private let noiseService: NoiseEncryptionService
|
private var noiseService: NoiseEncryptionService
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
private var myPeerIDData: Data = Data()
|
private var myPeerIDData: Data = Data()
|
||||||
|
|
||||||
// MARK: - Advertising Privacy
|
// MARK: - Advertising Privacy
|
||||||
@@ -138,6 +213,9 @@ final class BLEService: NSObject {
|
|||||||
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
||||||
// Debounce for 'reconnected' logs
|
// Debounce for 'reconnected' logs
|
||||||
private var lastReconnectLogAt: [String: Date] = [:]
|
private var lastReconnectLogAt: [String: Date] = [:]
|
||||||
|
|
||||||
|
// MARK: - Gossip Sync
|
||||||
|
private var gossipSyncManager: GossipSyncManager?
|
||||||
|
|
||||||
// MARK: - Maintenance Timer
|
// MARK: - Maintenance Timer
|
||||||
|
|
||||||
@@ -186,7 +264,9 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - Helpers: IDs, selection, and write backpressure
|
// MARK: - Helpers: IDs, selection, and write backpressure
|
||||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
private func makeMessageID(for packet: BitchatPacket) -> String {
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
let senderID = packet.senderID.hexEncodedString()
|
||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
let digest = SHA256.hash(data: packet.payload)
|
||||||
|
let digestPrefix = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
|
||||||
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subsetSizeForFanout(_ n: Int) -> Int {
|
private func subsetSizeForFanout(_ n: Int) -> Int {
|
||||||
@@ -326,33 +406,44 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
self.identityManager = identityManager
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
|
||||||
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
|
||||||
self.myPeerID = String(fingerprint.prefix(16))
|
|
||||||
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
|
||||||
|
|
||||||
// Set queue key for identification
|
|
||||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
|
||||||
|
|
||||||
// Set up Noise session establishment callback
|
|
||||||
// This ensures we send pending messages only when session is truly established
|
|
||||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
|
||||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
||||||
// Send any messages that were queued during handshake
|
|
||||||
self?.messageQueue.async { [weak self] in
|
self?.messageQueue.async { [weak self] in
|
||||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||||||
}
|
}
|
||||||
// Proactive presence nudge: announce immediately after handshake
|
|
||||||
self?.messageQueue.async { [weak self] in
|
self?.messageQueue.async { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPeerIdentity() {
|
||||||
|
let fingerprint = noiseService.getIdentityFingerprint()
|
||||||
|
myPeerID = String(fingerprint.prefix(16))
|
||||||
|
myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartGossipManager() {
|
||||||
|
gossipSyncManager?.stop()
|
||||||
|
let sync = GossipSyncManager(myPeerID: myPeerID)
|
||||||
|
sync.delegate = self
|
||||||
|
sync.start()
|
||||||
|
gossipSyncManager = sync
|
||||||
|
}
|
||||||
|
|
||||||
|
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
|
self.keychain = keychain
|
||||||
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
|
self.identityManager = identityManager
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
configureNoiseServiceCallbacks(for: noiseService)
|
||||||
|
refreshPeerIdentity()
|
||||||
|
|
||||||
|
// Set queue key for identification
|
||||||
|
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||||||
|
|
||||||
// Set up application state tracking (iOS only)
|
// Set up application state tracking (iOS only)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -398,9 +489,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
maintenanceTimer = timer
|
maintenanceTimer = timer
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
|
|
||||||
|
// Initialize gossip sync manager
|
||||||
|
restartGossipManager()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String) {
|
func setNickname(_ nickname: String) {
|
||||||
@@ -728,6 +822,46 @@ final class BLEService: NSObject {
|
|||||||
subscribedCentrals.removeAll()
|
subscribedCentrals.removeAll()
|
||||||
centralToPeerID.removeAll()
|
centralToPeerID.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetIdentityForPanic(currentNickname: String) {
|
||||||
|
messageQueue.sync(flags: .barrier) {
|
||||||
|
pendingMessagesAfterHandshake.removeAll()
|
||||||
|
pendingNoisePayloadsAfterHandshake.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
recentAnnounceBySender.removeAll()
|
||||||
|
recentAnnounceOrder.removeAll()
|
||||||
|
pendingPeripheralWrites.removeAll()
|
||||||
|
pendingNotifications.removeAll()
|
||||||
|
pendingDirectedRelays.removeAll()
|
||||||
|
ingressByMessageID.removeAll()
|
||||||
|
recentPacketTimestamps.removeAll()
|
||||||
|
scheduledRelays.values.forEach { $0.cancel() }
|
||||||
|
scheduledRelays.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
bleQueue.sync {
|
||||||
|
pendingWriteBuffers.removeAll()
|
||||||
|
recentConnectTimeouts.removeAll()
|
||||||
|
}
|
||||||
|
recentDisconnectNotifies.removeAll()
|
||||||
|
|
||||||
|
noiseService.clearEphemeralStateForPanic()
|
||||||
|
noiseService.clearPersistentIdentity()
|
||||||
|
|
||||||
|
let newNoise = NoiseEncryptionService(keychain: keychain)
|
||||||
|
noiseService = newNoise
|
||||||
|
configureNoiseServiceCallbacks(for: newNoise)
|
||||||
|
refreshPeerIdentity()
|
||||||
|
restartGossipManager()
|
||||||
|
|
||||||
|
setNickname(currentNickname)
|
||||||
|
|
||||||
|
messageDeduplicator.reset()
|
||||||
|
requestPeerDataPublish()
|
||||||
|
startServices()
|
||||||
|
}
|
||||||
|
|
||||||
func getNoiseService() -> NoiseEncryptionService {
|
func getNoiseService() -> NoiseEncryptionService {
|
||||||
return noiseService
|
return noiseService
|
||||||
@@ -775,6 +909,8 @@ final class BLEService: NSObject {
|
|||||||
self.messageDeduplicator.markProcessed(dedupID)
|
self.messageDeduplicator.markProcessed(dedupID)
|
||||||
// Call synchronously since we're already on background queue
|
// Call synchronously since we're already on background queue
|
||||||
self.broadcastPacket(signedPacket)
|
self.broadcastPacket(signedPacket)
|
||||||
|
// Track our own broadcast for sync
|
||||||
|
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1049,6 +1185,13 @@ final class BLEService: NSObject {
|
|||||||
// Determine last-hop link for this message to avoid echoing back
|
// Determine last-hop link for this message to avoid echoing back
|
||||||
let messageID = makeMessageID(for: packet)
|
let messageID = makeMessageID(for: packet)
|
||||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
||||||
|
let directedPeerHint: String? = {
|
||||||
|
if let explicit = directedOnlyPeer { return explicit }
|
||||||
|
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
|
||||||
|
return recipient
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
|
||||||
let states = snapshotPeripheralStates()
|
let states = snapshotPeripheralStates()
|
||||||
var minCentralWriteLen: Int?
|
var minCentralWriteLen: Int?
|
||||||
@@ -1099,10 +1242,13 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
||||||
// Special-case announces: do NOT subset to maximize reach for presence
|
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
|
if directedPeerHint == nil
|
||||||
|
&& packet.type != MessageType.fragment.rawValue
|
||||||
|
&& packet.type != MessageType.announce.rawValue
|
||||||
|
&& packet.type != MessageType.requestSync.rawValue {
|
||||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
||||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
||||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
||||||
@@ -1110,7 +1256,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If directed and we currently have no links to forward on, spool for a short window
|
// If directed and we currently have no links to forward on, spool for a short window
|
||||||
if let only = directedOnlyPeer,
|
if let only = directedPeerHint,
|
||||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||||
@@ -1133,6 +1279,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||||
|
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) {
|
||||||
|
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||||
|
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Directed store-and-forward
|
// MARK: - Directed store-and-forward
|
||||||
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
|
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
|
||||||
let msgID = makeMessageID(for: packet)
|
let msgID = makeMessageID(for: packet)
|
||||||
@@ -1339,7 +1491,10 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Efficient deduplication
|
// Efficient deduplication
|
||||||
// Important: do not dedup fragment packets globally (each piece must pass)
|
// Important: do not dedup fragment packets globally (each piece must pass)
|
||||||
if packet.type != MessageType.fragment.rawValue && messageDeduplicator.isDuplicate(messageID) {
|
// Special case: allow our own packets recovered via sync (TTL==0) to pass
|
||||||
|
// through even if we've marked them as seen at send time.
|
||||||
|
let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID)
|
||||||
|
if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) {
|
||||||
// Announce packets (type 1) are sent every 10 seconds for peer discovery
|
// Announce packets (type 1) are sent every 10 seconds for peer discovery
|
||||||
// It's normal to see these as duplicates - don't log them to reduce noise
|
// It's normal to see these as duplicates - don't log them to reduce noise
|
||||||
if packet.type != MessageType.announce.rawValue {
|
if packet.type != MessageType.announce.rawValue {
|
||||||
@@ -1383,6 +1538,9 @@ final class BLEService: NSObject {
|
|||||||
case .message:
|
case .message:
|
||||||
handleMessage(packet, from: senderID)
|
handleMessage(packet, from: senderID)
|
||||||
|
|
||||||
|
case .requestSync:
|
||||||
|
handleRequestSync(packet, from: senderID)
|
||||||
|
|
||||||
case .noiseHandshake:
|
case .noiseHandshake:
|
||||||
handleNoiseHandshake(packet, from: senderID)
|
handleNoiseHandshake(packet, from: senderID)
|
||||||
|
|
||||||
@@ -1583,12 +1741,17 @@ final class BLEService: NSObject {
|
|||||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
||||||
self.delegate?.didConnectToPeer(peerID)
|
self.delegate?.didConnectToPeer(peerID)
|
||||||
|
// Schedule initial unicast sync to this peer
|
||||||
|
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.requestPeerDataPublish()
|
self.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track for sync (include our own and others' announces)
|
||||||
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
|
|
||||||
// Send announce back for bidirectional discovery (only once per peer)
|
// Send announce back for bidirectional discovery (only once per peer)
|
||||||
let announceBackID = "announce-back-\(peerID)"
|
let announceBackID = "announce-back-\(peerID)"
|
||||||
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
|
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
|
||||||
@@ -1610,17 +1773,33 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||||
|
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
|
||||||
|
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
|
||||||
|
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
|
||||||
|
}
|
||||||
|
|
||||||
// Mention parsing moved to ChatViewModel
|
// Mention parsing moved to ChatViewModel
|
||||||
|
|
||||||
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
||||||
// Ignore self-origin public messages that may be seen again via relay
|
// Ignore self-origin public messages except when returned via sync (TTL==0).
|
||||||
if peerID == myPeerID { return }
|
// This allows our own messages to be surfaced when they come back via
|
||||||
|
// the sync path without re-processing regular relayed copies.
|
||||||
|
if peerID == myPeerID && packet.ttl != 0 { return }
|
||||||
|
|
||||||
var accepted = false
|
var accepted = false
|
||||||
var senderNickname: String = ""
|
var senderNickname: String = ""
|
||||||
|
|
||||||
if let info = peers[peerID], info.isVerifiedNickname {
|
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
||||||
|
if peerID == myPeerID {
|
||||||
|
accepted = true
|
||||||
|
senderNickname = myNickname
|
||||||
|
}
|
||||||
|
else if let info = peers[peerID], info.isVerifiedNickname {
|
||||||
// Known verified peer path
|
// Known verified peer path
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = info.nickname
|
senderNickname = info.nickname
|
||||||
@@ -1648,6 +1827,22 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If still not accepted and this is a sync-returned packet (TTL==0),
|
||||||
|
// accept with a generic nickname so history can be restored even for
|
||||||
|
// peers we haven't verified yet.
|
||||||
|
if !accepted && packet.ttl == 0 {
|
||||||
|
accepted = true
|
||||||
|
senderNickname = "anon" + String(peerID.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
|
||||||
|
let isBroadcastRecipient: Bool = {
|
||||||
|
guard let r = packet.recipientID else { return true }
|
||||||
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
if isBroadcastRecipient && packet.type == MessageType.message.rawValue {
|
||||||
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard accepted else {
|
guard accepted else {
|
||||||
@@ -1780,6 +1975,8 @@ final class BLEService: NSObject {
|
|||||||
// Remove the peer when they leave
|
// Remove the peer when they leave
|
||||||
peers.removeValue(forKey: peerID)
|
peers.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
|
// Remove any stored announcement for sync purposes
|
||||||
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
// Send on main thread
|
// Send on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -1861,6 +2058,8 @@ final class BLEService: NSObject {
|
|||||||
self?.broadcastPacket(signedPacket)
|
self?.broadcastPacket(signedPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ensure our own announce is included in sync state
|
||||||
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
@@ -2088,6 +2287,8 @@ final class BLEService: NSObject {
|
|||||||
if !peer.isConnected {
|
if !peer.isConnected {
|
||||||
if age > retention {
|
if age > retention {
|
||||||
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
|
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
|
||||||
|
// Also remove any stored announcement from sync candidates
|
||||||
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
peers.removeValue(forKey: peerID)
|
peers.removeValue(forKey: peerID)
|
||||||
removedOfflineCount += 1
|
removedOfflineCount += 1
|
||||||
}
|
}
|
||||||
@@ -2249,6 +2450,29 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - GossipSyncManager Delegate
|
||||||
|
extension BLEService: GossipSyncManager.Delegate {
|
||||||
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
broadcastPacket(packet)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPacket(to peerID: String, packet: BitchatPacket) {
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
sendPacketDirected(packet, to: peerID)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
|
return noiseService.signPacket(packet) ?? packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - CBCentralManagerDelegate
|
// MARK: - CBCentralManagerDelegate
|
||||||
|
|
||||||
extension BLEService: CBCentralManagerDelegate {
|
extension BLEService: CBCentralManagerDelegate {
|
||||||
@@ -2373,7 +2597,8 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
peerID: nil,
|
peerID: nil,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
lastConnectionAttempt: Date()
|
lastConnectionAttempt: Date(),
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
)
|
)
|
||||||
peripheral.delegate = self
|
peripheral.delegate = self
|
||||||
|
|
||||||
@@ -2422,7 +2647,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
characteristic: nil,
|
characteristic: nil,
|
||||||
peerID: nil,
|
peerID: nil,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
isConnected: true
|
isConnected: true,
|
||||||
|
lastConnectionAttempt: nil,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2560,7 +2787,8 @@ extension BLEService {
|
|||||||
peerID: nil,
|
peerID: nil,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
lastConnectionAttempt: Date()
|
lastConnectionAttempt: Date(),
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
)
|
)
|
||||||
peripheral.delegate = self
|
peripheral.delegate = self
|
||||||
let options: [String: Any] = [
|
let options: [String: Any] = [
|
||||||
@@ -2697,53 +2925,72 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = characteristic.value else {
|
guard let data = characteristic.value, !data.isEmpty else {
|
||||||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Received BLE notification
|
bufferNotificationChunk(data, from: peripheral)
|
||||||
|
|
||||||
// Process directly on main thread to avoid deadlocks (matches original implementation)
|
|
||||||
guard let packet = BinaryProtocol.decode(data) else {
|
|
||||||
// Avoid dumping entire payload; log size and short prefix for diagnostics
|
|
||||||
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
|
||||||
SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the packet's senderID as the peer identifier
|
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
|
||||||
// Only log non-announce packets
|
|
||||||
if packet.type != MessageType.announce.rawValue {
|
|
||||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||||||
let peripheralUUID = peripheral.identifier.uuidString
|
let peripheralUUID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
// Update mapping ONLY for announce packets that come directly from the peer (not relayed)
|
var state = peripherals[peripheralUUID] ?? PeripheralState(
|
||||||
|
peripheral: peripheral,
|
||||||
|
characteristic: nil,
|
||||||
|
peerID: nil,
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: peripheral.state == .connected,
|
||||||
|
lastConnectionAttempt: nil,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
|
)
|
||||||
|
|
||||||
|
var assembler = state.assembler
|
||||||
|
let result = assembler.append(chunk)
|
||||||
|
state.assembler = assembler
|
||||||
|
peripherals[peripheralUUID] = state
|
||||||
|
|
||||||
|
for byte in result.droppedPrefixes {
|
||||||
|
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.reset {
|
||||||
|
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
for frame in result.frames {
|
||||||
|
guard let packet = BinaryProtocol.decode(frame) else {
|
||||||
|
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||||
|
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) {
|
||||||
|
let senderID = packet.senderID.hexEncodedString()
|
||||||
|
|
||||||
|
if packet.type != MessageType.announce.rawValue {
|
||||||
|
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
if packet.type == MessageType.announce.rawValue {
|
if packet.type == MessageType.announce.rawValue {
|
||||||
// Only update mapping if this is a direct announce (TTL == messageTTL means not relayed)
|
|
||||||
if packet.ttl == messageTTL {
|
if packet.ttl == messageTTL {
|
||||||
if var state = peripherals[peripheralUUID] {
|
if var state = peripherals[peripheralUUID] {
|
||||||
state.peerID = senderID
|
state.peerID = senderID
|
||||||
peripherals[peripheralUUID] = state
|
peripherals[peripheralUUID] = state
|
||||||
}
|
}
|
||||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||||
// Mapping update - direct announce from peer
|
|
||||||
}
|
}
|
||||||
// Record ingress link for last-hop suppression and process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
let msgID = makeMessageID(for: packet)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||||
}
|
}
|
||||||
// Process the announce packet regardless of whether we updated the mapping
|
|
||||||
handleReceivedPacket(packet, from: senderID)
|
handleReceivedPacket(packet, from: senderID)
|
||||||
} else {
|
} else {
|
||||||
// For non-announce packets, DO NOT update mappings
|
|
||||||
// These could be relayed packets from other peers
|
|
||||||
// Always use the packet's original senderID
|
|
||||||
// Record ingress link for last-hop suppression and process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
let msgID = makeMessageID(for: packet)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -23,8 +24,8 @@ protocol KeychainManagerProtocol {
|
|||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
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 func isSandboxed() -> Bool {
|
private func isSandboxed() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -281,6 +282,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
"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"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
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).
|
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesCounter: ObservableObject {
|
final class LocationNotesCounter: ObservableObject {
|
||||||
@@ -8,29 +37,45 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
@Published private(set) var geohash: String? = nil
|
@Published private(set) var geohash: String? = nil
|
||||||
@Published private(set) var count: Int? = 0
|
@Published private(set) var count: Int? = 0
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var relayAvailable: Bool = true
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
private var subscriptionID: String? = nil
|
||||||
private var noteIDs = Set<String>()
|
private var noteIDs = Set<String>()
|
||||||
|
private let dependencies: LocationNotesCounterDependencies
|
||||||
|
|
||||||
private init() {}
|
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
init(testDependencies: LocationNotesCounterDependencies) {
|
||||||
|
self.dependencies = testDependencies
|
||||||
|
}
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
|
relayAvailable = true
|
||||||
|
|
||||||
// Subscribe only to the building geohash (precision 8)
|
// Subscribe only to the building geohash (precision 8)
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
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
|
subscriptionID = subID
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||||
@@ -38,16 +83,17 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
self.count = self.noteIDs.count
|
self.count = self.noteIDs.count
|
||||||
}
|
}
|
||||||
}, onEOSE: { [weak self] in
|
}, { [weak self] in
|
||||||
self?.initialLoadComplete = true
|
self?.initialLoadComplete = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
geohash = nil
|
geohash = nil
|
||||||
count = 0
|
count = 0
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
|
relayAvailable = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,57 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
/// 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.
|
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesManager: ObservableObject {
|
final class LocationNotesManager: ObservableObject {
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case noRelays
|
||||||
|
}
|
||||||
|
|
||||||
struct Note: Identifiable, Equatable {
|
struct Note: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let pubkey: String
|
let pubkey: String
|
||||||
@@ -23,10 +71,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
||||||
@Published private(set) var geohash: String
|
@Published private(set) var geohash: String
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@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 subscriptionID: String?
|
||||||
|
private let dependencies: LocationNotesDependencies
|
||||||
|
|
||||||
init(geohash: String) {
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
self.geohash = geohash.lowercased()
|
self.geohash = geohash.lowercased()
|
||||||
|
self.dependencies = dependencies
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +86,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
geohash = norm
|
geohash = norm
|
||||||
@@ -42,15 +94,43 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
notes.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func subscribe() {
|
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 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 = "No geo relays available near this location. Try again soon."
|
||||||
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
|
initialLoadComplete = false
|
||||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
initialLoadComplete = false
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag
|
||||||
@@ -61,8 +141,13 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
}, onEOSE: { [weak self] in
|
self.state = .ready
|
||||||
self?.initialLoadComplete = true
|
}, { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.initialLoadComplete = true
|
||||||
|
if self.state != .noRelays {
|
||||||
|
self.state = .ready
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,29 +155,46 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
func send(content: String, nickname: String) {
|
func send(content: String, nickname: String) {
|
||||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = "No geo relays available near this location. Try again soon."
|
||||||
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
let id = try dependencies.deriveIdentity(geohash)
|
||||||
let event = try NostrProtocol.createGeohashTextNote(
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: id,
|
senderIdentity: id,
|
||||||
nickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
dependencies.sendEvent(event, relays)
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
// Optimistic local-echo
|
// Optimistic local-echo
|
||||||
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
|
let echo = Note(
|
||||||
|
id: event.id,
|
||||||
|
pubkey: id.publicKeyHex,
|
||||||
|
content: trimmed,
|
||||||
|
createdAt: dependencies.now(),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.state = .ready
|
||||||
|
self.errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
|
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicitly cancel subscription and release resources.
|
/// Explicitly cancel subscription and release resources.
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
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
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
@@ -525,6 +525,15 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
SecureLogger.info(.sessionExpired(peerID: peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearEphemeralStateForPanic() {
|
||||||
|
sessionManager.removeAllSessions()
|
||||||
|
serviceQueue.sync(flags: .barrier) {
|
||||||
|
peerFingerprints.removeAll()
|
||||||
|
fingerprintToPeerID.removeAll()
|
||||||
|
}
|
||||||
|
rateLimiter.resetAll()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Darwin
|
import Darwin
|
||||||
@@ -67,19 +68,27 @@ final class TorManager: ObservableObject {
|
|||||||
private var controlMonitorStarted = false
|
private var controlMonitorStarted = false
|
||||||
private var pathMonitor: NWPathMonitor?
|
private var pathMonitor: NWPathMonitor?
|
||||||
private var isAppForeground: Bool = true
|
private var isAppForeground: Bool = true
|
||||||
|
private var isDormant: Bool = false
|
||||||
private var lastRestartAt: Date? = nil
|
private var lastRestartAt: Date? = nil
|
||||||
|
// Global policy gate: only allow Tor to start when true
|
||||||
|
private(set) var allowAutoStart: Bool = false
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
func startIfNeeded() {
|
func startIfNeeded() {
|
||||||
|
// Respect global start policy
|
||||||
|
guard allowAutoStart else { return }
|
||||||
// Do not start in background; caller should wait for foreground
|
// Do not start in background; caller should wait for foreground
|
||||||
guard isAppForeground else { return }
|
guard isAppForeground else { return }
|
||||||
guard !didStart else { return }
|
guard !didStart else { return }
|
||||||
didStart = true
|
didStart = true
|
||||||
|
isDormant = false
|
||||||
isStarting = true
|
isStarting = true
|
||||||
lastError = nil
|
lastError = nil
|
||||||
|
// Announce initial start so UI can show a status message
|
||||||
|
NotificationCenter.default.post(name: .TorWillStart, object: nil)
|
||||||
ensureFilesystemLayout()
|
ensureFilesystemLayout()
|
||||||
startTor()
|
startTor()
|
||||||
startPathMonitorIfNeeded()
|
startPathMonitorIfNeeded()
|
||||||
@@ -472,6 +481,8 @@ final class TorManager: ObservableObject {
|
|||||||
// MARK: - Foreground recovery and control helpers
|
// MARK: - Foreground recovery and control helpers
|
||||||
|
|
||||||
func ensureRunningOnForeground() {
|
func ensureRunningOnForeground() {
|
||||||
|
// Respect global start policy
|
||||||
|
if !allowAutoStart { return }
|
||||||
// iOS can suspend Tor harshly; the most reliable approach for
|
// iOS can suspend Tor harshly; the most reliable approach for
|
||||||
// embedding is to restart Tor every time we become active.
|
// embedding is to restart Tor every time we become active.
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
@@ -483,18 +494,39 @@ final class TorManager: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !claimed { return }
|
if !claimed { return }
|
||||||
|
if await self.resumeTorIfPossible() {
|
||||||
|
await MainActor.run {
|
||||||
|
self.restarting = false
|
||||||
|
self.isStarting = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
await self.restartTor()
|
await self.restartTor()
|
||||||
await MainActor.run { self.restarting = false }
|
await MainActor.run { self.restarting = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func goDormantOnBackground() {
|
func goDormantOnBackground() {
|
||||||
// Stricter model: fully stop Tor when app backgrounds to save power
|
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart.
|
||||||
// and avoid half-suspended states. We'll restart cleanly on .active.
|
// If the control port is unreachable, fall back to a hard shutdown.
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
let signaled = await self.controlSendSignal("DORMANT")
|
||||||
|
if signaled {
|
||||||
|
SecureLogger.info("TorManager: signalled DORMANT", category: .session)
|
||||||
|
await MainActor.run {
|
||||||
|
self.isDormant = true
|
||||||
|
self.isReady = false
|
||||||
|
self.socksReady = false
|
||||||
|
self.isStarting = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.warning("TorManager: DORMANT signal failed; shutting down", category: .session)
|
||||||
_ = tor_host_shutdown()
|
_ = tor_host_shutdown()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
self.isDormant = false
|
||||||
self.isReady = false
|
self.isReady = false
|
||||||
self.socksReady = false
|
self.socksReady = false
|
||||||
self.bootstrapProgress = 0
|
self.bootstrapProgress = 0
|
||||||
@@ -507,6 +539,24 @@ final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shutdownCompletely() {
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
_ = tor_host_shutdown()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isDormant = false
|
||||||
|
self.isReady = false
|
||||||
|
self.socksReady = false
|
||||||
|
self.bootstrapProgress = 0
|
||||||
|
self.bootstrapSummary = ""
|
||||||
|
self.isStarting = false
|
||||||
|
self.didStart = false
|
||||||
|
self.restarting = false
|
||||||
|
self.controlMonitorStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func restartTor() async {
|
private func restartTor() async {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
// Announce restart so UI can notify the user
|
// Announce restart so UI can notify the user
|
||||||
@@ -516,14 +566,28 @@ final class TorManager: ObservableObject {
|
|||||||
self.bootstrapProgress = 0
|
self.bootstrapProgress = 0
|
||||||
self.bootstrapSummary = ""
|
self.bootstrapSummary = ""
|
||||||
self.isStarting = true
|
self.isStarting = true
|
||||||
|
self.isDormant = false
|
||||||
self.lastRestartAt = Date()
|
self.lastRestartAt = Date()
|
||||||
}
|
}
|
||||||
// Prefer clean shutdown via owning controller FD; join the tor thread
|
// Prefer clean shutdown via owning controller FD; join the tor thread
|
||||||
_ = tor_host_shutdown()
|
_ = tor_host_shutdown()
|
||||||
// As a fallback, try control signal if needed (harmless if tor already down)
|
// As a fallback, try control signal if needed (harmless if tor already down)
|
||||||
_ = await controlSendSignal("SHUTDOWN")
|
_ = await controlSendSignal("SHUTDOWN")
|
||||||
|
// Allow Tor thread to fully terminate before re-starting.
|
||||||
|
var waited = 0
|
||||||
|
while tor_host_is_running() != 0 && waited < 40 {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||||
|
waited += 1
|
||||||
|
}
|
||||||
|
if waited >= 40 {
|
||||||
|
SecureLogger.warning("TorManager: tor_host_is_running still true before restart", category: .session)
|
||||||
|
}
|
||||||
|
// Allow control monitor and start logic to reinitialize cleanly
|
||||||
|
await MainActor.run {
|
||||||
|
self.controlMonitorStarted = false
|
||||||
|
self.didStart = false
|
||||||
|
}
|
||||||
// Now start fresh
|
// Now start fresh
|
||||||
await MainActor.run { self.didStart = false }
|
|
||||||
await MainActor.run { self.startIfNeeded() }
|
await MainActor.run { self.startIfNeeded() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +653,62 @@ final class TorManager: ObservableObject {
|
|||||||
return (text?.contains("250")) == true
|
return (text?.contains("250")) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resumeTorIfPossible() async -> Bool {
|
||||||
|
let wasDormant = await MainActor.run { self.isDormant }
|
||||||
|
let pendingReady = await MainActor.run { self.socksReady && !self.isReady }
|
||||||
|
let needsWake = wasDormant || pendingReady
|
||||||
|
if !needsWake {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let activated = await controlSendSignal("ACTIVE")
|
||||||
|
let pinged = await controlPingBootstrap(timeout: 3.0)
|
||||||
|
if !activated && !pinged {
|
||||||
|
SecureLogger.warning("TorManager: ACTIVE signal failed", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let info = await controlGetBootstrapInfo() {
|
||||||
|
await MainActor.run {
|
||||||
|
self.bootstrapProgress = info.progress
|
||||||
|
self.bootstrapSummary = info.summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.isDormant = false
|
||||||
|
self.isStarting = true
|
||||||
|
self.socksReady = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstReady = await waitForSocksReady(timeout: 12.0)
|
||||||
|
if firstReady {
|
||||||
|
await MainActor.run {
|
||||||
|
self.socksReady = true
|
||||||
|
self.isStarting = false
|
||||||
|
}
|
||||||
|
SecureLogger.info("TorManager: resumed Tor via ACTIVE signal", category: .session)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if pinged {
|
||||||
|
let secondReady = await waitForSocksReady(timeout: 20.0)
|
||||||
|
await MainActor.run {
|
||||||
|
self.socksReady = secondReady
|
||||||
|
self.isStarting = !secondReady
|
||||||
|
}
|
||||||
|
if secondReady {
|
||||||
|
SecureLogger.info("TorManager: resumed Tor after extended wait", category: .session)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run { self.isStarting = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.warning("TorManager: ACTIVE resume failed; will restart", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
private func controlExchange(lines: [String], timeout: TimeInterval) async -> String? {
|
||||||
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
guard let cookiePath = dataDirectoryURL()?.appendingPathComponent("control_auth_cookie"),
|
||||||
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
let cookie = try? Data(contentsOf: cookiePath) else { return nil }
|
||||||
@@ -635,3 +755,14 @@ final class TorManager: ObservableObject {
|
|||||||
return resultText
|
return resultText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Start policy configuration
|
||||||
|
extension TorManager {
|
||||||
|
@MainActor
|
||||||
|
func setAutoStartAllowed(_ allow: Bool) {
|
||||||
|
allowAutoStart = allow
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func isAutoStartAllowed() -> Bool { allowAutoStart }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ import Foundation
|
|||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||||
|
static let TorWillStart = Notification.Name("TorWillStart")
|
||||||
|
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,43 +4,34 @@ import CFNetwork
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
||||||
/// when Tor is enforced/ready. Falls back to a default session only when
|
/// when Tor is enforced/ready. Allows swapping between proxied and direct
|
||||||
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
/// sessions so UI can toggle Tor usage at runtime.
|
||||||
final class TorURLSession {
|
final class TorURLSession {
|
||||||
static let shared = TorURLSession()
|
static let shared = TorURLSession()
|
||||||
|
|
||||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
// Default (no proxy) session for direct Nostr access when Tor is disabled.
|
||||||
private var defaultSession: URLSession = {
|
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
|
||||||
let cfg = URLSessionConfiguration.default
|
|
||||||
cfg.waitsForConnectivity = true
|
|
||||||
return URLSession(configuration: cfg)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Proxied (SOCKS5) session that routes through Tor.
|
// Proxied (SOCKS5) session that routes through Tor.
|
||||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||||
|
private var useTorProxy: Bool = true
|
||||||
|
|
||||||
var session: URLSession {
|
var session: URLSession {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
useTorProxy ? torSession : defaultSession
|
||||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
|
||||||
return defaultSession
|
|
||||||
#else
|
|
||||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
|
||||||
return torSession
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||||
func rebuild() {
|
func rebuild() {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
defaultSession = TorURLSession.makeDefaultSession()
|
||||||
defaultSession = {
|
|
||||||
let cfg = URLSessionConfiguration.default
|
|
||||||
cfg.waitsForConnectivity = true
|
|
||||||
return URLSession(configuration: cfg)
|
|
||||||
}()
|
|
||||||
#endif
|
|
||||||
torSession = TorURLSession.makeTorSession()
|
torSession = TorURLSession.makeTorSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setProxyMode(useTor: Bool) {
|
||||||
|
guard useTorProxy != useTor else { return }
|
||||||
|
useTorProxy = useTor
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
private static func makeTorSession() -> URLSession {
|
private static func makeTorSession() -> URLSession {
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
cfg.waitsForConnectivity = true
|
cfg.waitsForConnectivity = true
|
||||||
@@ -63,4 +54,10 @@ final class TorURLSession {
|
|||||||
#endif
|
#endif
|
||||||
return URLSession(configuration: cfg)
|
return URLSession(configuration: cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeDefaultSession() -> URLSession {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.waitsForConnectivity = true
|
||||||
|
return URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -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,210 @@
|
|||||||
|
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: String, 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%
|
||||||
|
}
|
||||||
|
|
||||||
|
private let myPeerID: String
|
||||||
|
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: String, 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?.sendRequestSync() }
|
||||||
|
timer.resume()
|
||||||
|
periodicTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
periodicTimer?.cancel(); periodicTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleInitialSyncToPeer(_ peerID: String, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
|
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) ?? 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: String) {
|
||||||
|
let payload = buildGcsPayload()
|
||||||
|
var recipient = Data()
|
||||||
|
var temp = peerID
|
||||||
|
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) ?? 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(fromPeerID: String, request: RequestSyncPacket) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _handleRequestSync(fromPeerID: String, 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
|
||||||
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
|
let (idHex, pkt) = pair
|
||||||
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Broadcast messages: send all missing
|
||||||
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
|
for pkt in toSendMsgs {
|
||||||
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
|
if !mightContain(idBytes) {
|
||||||
|
var toSend = pkt
|
||||||
|
toSend.ttl = 0
|
||||||
|
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
|
private func buildGcsPayload() -> Data {
|
||||||
|
// Collect candidates: latest announce per peer + broadcast messages
|
||||||
|
var candidates: [BitchatPacket] = []
|
||||||
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
|
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
||||||
|
for id in messageOrder { if let p = messages[id] { 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
|
func removeAnnouncementForPeer(_ peerID: String) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||||
|
let normalizedPeerID = peerID.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,21 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
||||||
|
return computeId(packet).hexEncodedString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,37 @@
|
|||||||
|
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 ..<14.0:
|
||||||
|
return .footnote
|
||||||
|
case ..<16.5:
|
||||||
|
return .body
|
||||||
|
case ..<19.0:
|
||||||
|
return .title3
|
||||||
|
case ..<23.0:
|
||||||
|
return .title2
|
||||||
|
case ..<30.0:
|
||||||
|
return .title
|
||||||
|
default:
|
||||||
|
return .largeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ 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
|
||||||
|
// for headers/padding by limiting user content to 60_000 bytes.
|
||||||
|
static let maxMessageLength = 60_000
|
||||||
static let maxReasonLength = 200
|
static let maxReasonLength = 200
|
||||||
static let maxPeerIDLength = 64
|
static let maxPeerIDLength = 64
|
||||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1083
-1009
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct AppInfoView: View {
|
struct AppInfoView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -93,7 +88,7 @@ struct AppInfoView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
@@ -111,16 +106,29 @@ 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)
|
||||||
|
|
||||||
|
// How to Use
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
SectionHeader(Strings.HowToUse.title)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
||||||
|
Text(instruction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Features.title)
|
SectionHeader(Strings.Features.title)
|
||||||
@@ -167,26 +175,13 @@ struct AppInfoView: View {
|
|||||||
description: Strings.Privacy.panic.2)
|
description: Strings.Privacy.panic.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// How to Use
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
SectionHeader(Strings.HowToUse.title)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
|
||||||
Text(instruction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -196,77 +191,12 @@ struct AppInfoView: View {
|
|||||||
.background(Color.red.opacity(0.1))
|
.background(Color.red.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
#if DEBUG
|
.padding(.top)
|
||||||
// Debug section (visible only in Debug builds)
|
|
||||||
DebugLogsSection(textColor: textColor, secondaryTextColor: secondaryTextColor)
|
|
||||||
.padding(.top)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private struct DebugLogsSection: View {
|
|
||||||
let textColor: Color
|
|
||||||
let secondaryTextColor: Color
|
|
||||||
@State private var logsText: String = SecureLogger.getLogText()
|
|
||||||
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader("DEBUG")
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
copyToPasteboard(logsText)
|
|
||||||
} label: {
|
|
||||||
Text("copy logs")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
SecureLogger.clearLogs()
|
|
||||||
logsText = ""
|
|
||||||
} label: {
|
|
||||||
Text("clear logs")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
Text(logsText.isEmpty ? "(no logs)" : logsText)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
.frame(minHeight: 180)
|
|
||||||
.background(secondaryTextColor.opacity(0.08))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
.onReceive(timer) { _ in
|
|
||||||
logsText = SecureLogger.getLogText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func copyToPasteboard(_ text: String) {
|
|
||||||
#if os(iOS)
|
|
||||||
UIPasteboard.general.string = text
|
|
||||||
#elseif os(macOS)
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
pb.clearContents()
|
|
||||||
pb.setString(text, forType: .string)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct SectionHeader: View {
|
struct SectionHeader: View {
|
||||||
let title: String
|
let title: String
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -281,7 +211,7 @@ struct SectionHeader: View {
|
|||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -304,17 +234,17 @@ struct FeatureRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: 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(title)
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(description)
|
Text(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)
|
||||||
}
|
}
|
||||||
@@ -324,6 +254,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct ContentView: View {
|
|||||||
@State private var textFieldSelection: NSRange? = nil
|
@State private var textFieldSelection: NSRange? = nil
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@State private var showPeerList = false
|
@State private var showPeerList = false
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var sidebarDragOffset: CGFloat = 0
|
@State private var sidebarDragOffset: CGFloat = 0
|
||||||
@@ -53,6 +54,7 @@ struct ContentView: View {
|
|||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
@State private var sheetNotesCount: Int = 0
|
@State private var sheetNotesCount: Int = 0
|
||||||
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@@ -63,14 +65,18 @@ struct ContentView: View {
|
|||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var secondaryTextColor: Color {
|
private var secondaryTextColor: Color {
|
||||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var headerLineLimit: Int? {
|
||||||
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ struct ContentView: View {
|
|||||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
else { expandedMessageIDs.insert(message.id) }
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
}
|
}
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(Color.blue)
|
.foregroundColor(Color.blue)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
@@ -462,7 +468,12 @@ struct ContentView: View {
|
|||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showMessageActions = true
|
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
|
||||||
|
selectedMessageSender = nil
|
||||||
|
selectedMessageSenderID = nil
|
||||||
|
} else {
|
||||||
|
showMessageActions = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
guard url.scheme == "bitchat", url.host == "geohash" else { return }
|
guard url.scheme == "bitchat", url.host == "geohash" else { return }
|
||||||
@@ -701,7 +712,7 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(suggestion)
|
Text(suggestion)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -759,14 +770,14 @@ struct ContentView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
// Show all aliases together
|
// Show all aliases together
|
||||||
Text(info.commands.joined(separator: ", "))
|
Text(info.commands.joined(separator: ", "))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
|
||||||
// Show syntax if any
|
// Show syntax if any
|
||||||
if let syntax = info.syntax {
|
if let syntax = info.syntax {
|
||||||
Text(syntax)
|
Text(syntax)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +785,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Show description
|
// Show description
|
||||||
Text(info.description)
|
Text(info.description)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -797,7 +808,7 @@ struct ContentView: View {
|
|||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
TextField("type a message...", text: $messageText)
|
TextField("type a message...", text: $messageText)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.focused($isTextFieldFocused)
|
.focused($isTextFieldFocused)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
@@ -872,7 +883,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button(action: sendMessage) {
|
Button(action: sendMessage) {
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(messageText.isEmpty ? Color.gray :
|
.foregroundColor(messageText.isEmpty ? Color.gray :
|
||||||
viewModel.selectedPrivateChatPeer != nil
|
viewModel.selectedPrivateChatPeer != nil
|
||||||
? Color.orange : textColor)
|
? Color.orange : textColor)
|
||||||
@@ -913,20 +924,20 @@ struct ContentView: View {
|
|||||||
// Header - match main toolbar height
|
// Header - match main toolbar height
|
||||||
HStack {
|
HStack {
|
||||||
Text("PEOPLE")
|
Text("PEOPLE")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
// Show QR in mesh on all platforms
|
// Show QR in mesh on all platforms
|
||||||
if case .mesh = locationManager.selectedChannel {
|
if case .mesh = locationManager.selectedChannel {
|
||||||
Button(action: { showVerifySheet = true }) {
|
Button(action: { showVerifySheet = true }) {
|
||||||
Image(systemName: "qrcode")
|
Image(systemName: "qrcode")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Verification: show my QR or scan a friend")
|
.help("Verification: show my QR or scan a friend")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Match header height
|
.frame(height: headerHeight) // Match header height
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
|
|
||||||
@@ -1077,7 +1088,7 @@ struct ContentView: View {
|
|||||||
private var mainHeaderView: some View {
|
private var mainHeaderView: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("bitchat/")
|
Text("bitchat/")
|
||||||
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
// PANIC: Triple-tap to clear all data
|
// PANIC: Triple-tap to clear all data
|
||||||
@@ -1090,12 +1101,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("@")
|
Text("@")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
|
|
||||||
TextField("nickname", text: $viewModel.nickname)
|
TextField("nickname", text: $viewModel.nickname)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.frame(maxWidth: 80)
|
.frame(maxWidth: 80)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.focused($isNicknameFieldFocused)
|
.focused($isNicknameFieldFocused)
|
||||||
@@ -1134,7 +1145,7 @@ struct ContentView: View {
|
|||||||
if viewModel.hasAnyUnreadMessages {
|
if viewModel.hasAnyUnreadMessages {
|
||||||
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -1154,7 +1165,7 @@ struct ContentView: View {
|
|||||||
let currentCount = (notesCounter.count ?? 0)
|
let currentCount = (notesCounter.count ?? 0)
|
||||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
Image(systemName: "long.text.page.and.pencil")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
}
|
}
|
||||||
@@ -1168,7 +1179,7 @@ struct ContentView: View {
|
|||||||
if case .location(let ch) = locationManager.selectedChannel {
|
if case .location(let ch) = locationManager.selectedChannel {
|
||||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||||
@@ -1191,9 +1202,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
Text(badgeText)
|
Text(badgeText)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(badgeColor)
|
.foregroundColor(badgeColor)
|
||||||
.lineLimit(1)
|
.lineLimit(headerLineLimit)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.layoutPriority(2)
|
.layoutPriority(2)
|
||||||
.accessibilityLabel("location channels")
|
.accessibilityLabel("location channels")
|
||||||
@@ -1205,15 +1216,15 @@ struct ContentView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
// People icon with count
|
// People icon with count
|
||||||
Image(systemName: "person.2.fill")
|
Image(systemName: "person.2.fill")
|
||||||
.font(.system(size: 11))
|
.font(.bitchatSystem(size: 11))
|
||||||
.accessibilityLabel("\(headerOtherPeersCount) people")
|
.accessibilityLabel("\(headerOtherPeersCount) people")
|
||||||
Text("\(headerOtherPeersCount)")
|
Text("\(headerOtherPeersCount)")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
.lineLimit(1)
|
.lineLimit(headerLineLimit)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
|
||||||
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||||
@@ -1230,7 +1241,7 @@ struct ContentView: View {
|
|||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||||
@@ -1246,22 +1257,22 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("notes")
|
Text("notes")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { showLocationNotes = false }) {
|
Button(action: { showLocationNotes = false }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel("Close")
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
Text("location unavailable")
|
Text("location unavailable")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
Button("enable location") {
|
Button("enable location") {
|
||||||
LocationChannelManager.shared.enableLocationChannels()
|
LocationChannelManager.shared.enableLocationChannels()
|
||||||
@@ -1412,19 +1423,19 @@ struct ContentView: View {
|
|||||||
case .bluetoothConnected:
|
case .bluetoothConnected:
|
||||||
// Radio icon for mesh connection
|
// Radio icon for mesh connection
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
case .meshReachable:
|
case .meshReachable:
|
||||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
// point.3 filled icon for reachable via mesh (not directly connected)
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Reachable via mesh")
|
.accessibilityLabel("Reachable via mesh")
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
// Purple globe for Nostr
|
// Purple globe for Nostr
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.accessibilityLabel("Available via Nostr")
|
||||||
case .offline:
|
case .offline:
|
||||||
@@ -1434,25 +1445,25 @@ struct ContentView: View {
|
|||||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
||||||
// Fallback: reachable via mesh but not in current peer list
|
// Fallback: reachable via mesh but not in current peer list
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Reachable via mesh")
|
.accessibilityLabel("Reachable via mesh")
|
||||||
} else if isNostrAvailable {
|
} else if isNostrAvailable {
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.accessibilityLabel("Available via Nostr")
|
||||||
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
||||||
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("\(privatePeerNick)")
|
Text("\(privatePeerNick)")
|
||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||||
@@ -1465,7 +1476,7 @@ struct ContentView: View {
|
|||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||||
encryptionStatus == .noiseSecured ? textColor :
|
encryptionStatus == .noiseSecured ? textColor :
|
||||||
Color.red)
|
Color.red)
|
||||||
@@ -1487,7 +1498,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 44, height: 44, alignment: .leading)
|
.frame(width: 44, height: 44, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -1503,7 +1514,7 @@ struct ContentView: View {
|
|||||||
viewModel.toggleFavorite(peerID: headerPeerID)
|
viewModel.toggleFavorite(peerID: headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
||||||
.font(.system(size: 16))
|
.font(.bitchatSystem(size: 16))
|
||||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -1512,7 +1523,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
@@ -1569,7 +1580,7 @@ private struct PaymentChipView: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(emoji)
|
Text(emoji)
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -1610,20 +1621,20 @@ struct DeliveryStatusView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .sending:
|
case .sending:
|
||||||
Image(systemName: "circle")
|
Image(systemName: "circle")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
case .sent:
|
case .sent:
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
case .delivered(let nickname, _):
|
case .delivered(let nickname, _):
|
||||||
HStack(spacing: -2) {
|
HStack(spacing: -2) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor.opacity(0.8))
|
.foregroundColor(textColor.opacity(0.8))
|
||||||
.help("Delivered to \(nickname)")
|
.help("Delivered to \(nickname)")
|
||||||
@@ -1631,25 +1642,25 @@ struct DeliveryStatusView: View {
|
|||||||
case .read(let nickname, _):
|
case .read(let nickname, _):
|
||||||
HStack(spacing: -2) {
|
HStack(spacing: -2) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
}
|
}
|
||||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||||
.help("Read by \(nickname)")
|
.help("Read by \(nickname)")
|
||||||
|
|
||||||
case .failed(let reason):
|
case .failed(let reason):
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(Color.red.opacity(0.8))
|
.foregroundColor(Color.red.opacity(0.8))
|
||||||
.help("Failed: \(reason)")
|
.help("Failed: \(reason)")
|
||||||
|
|
||||||
case .partiallyDelivered(let reached, let total):
|
case .partiallyDelivered(let reached, let total):
|
||||||
HStack(spacing: 1) {
|
HStack(spacing: 1) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
Text("\(reached)/\(total)")
|
Text("\(reached)/\(total)")
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
}
|
}
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
.help("Delivered to \(reached) of \(total) members")
|
.help("Delivered to \(reached) of \(total) members")
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ struct FingerprintView: View {
|
|||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
Text("SECURITY VERIFICATION")
|
Text("SECURITY VERIFICATION")
|
||||||
.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)
|
||||||
}
|
}
|
||||||
@@ -66,17 +66,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +89,12 @@ struct FingerprintView: View {
|
|||||||
// Their fingerprint
|
// Their fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("THEIR FINGERPRINT:")
|
Text("THEIR FINGERPRINT:")
|
||||||
.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)
|
||||||
@@ -115,7 +115,7 @@ struct FingerprintView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("not available - handshake in progress")
|
Text("not available - handshake in progress")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -124,12 +124,12 @@ struct FingerprintView: View {
|
|||||||
// My fingerprint
|
// My fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("YOUR FINGERPRINT:")
|
Text("YOUR FINGERPRINT:")
|
||||||
.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)
|
||||||
@@ -156,14 +156,14 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
||||||
.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 ?
|
Text(isVerified ?
|
||||||
"you have verified this person's identity." :
|
"you have verified this person's identity." :
|
||||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -176,7 +176,7 @@ struct FingerprintView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("MARK AS VERIFIED")
|
Text("MARK AS VERIFIED")
|
||||||
.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)
|
||||||
@@ -190,7 +190,7 @@ struct FingerprintView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("REMOVE VERIFICATION")
|
Text("REMOVE VERIFICATION")
|
||||||
.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)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct GeohashPeopleList: View {
|
|||||||
if viewModel.visibleGeohashPeople().isEmpty {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.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,30 +51,30 @@ 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) = splitSuffix(from: person.displayName)
|
||||||
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(" (you)")
|
||||||
.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("Blocked in geochash")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 = ""
|
||||||
@@ -19,10 +20,14 @@ struct LocationChannelsSheet: View {
|
|||||||
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("#location channels")
|
||||||
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
closeButton
|
||||||
|
}
|
||||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
|
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
@@ -30,7 +35,7 @@ struct LocationChannelsSheet: View {
|
|||||||
case LocationChannelManager.PermissionState.notDetermined:
|
case LocationChannelManager.PermissionState.notDetermined:
|
||||||
Button(action: { manager.enableLocationChannels() }) {
|
Button(action: { manager.enableLocationChannels() }) {
|
||||||
Text("get location and my geohashes")
|
Text("get location and my geohashes")
|
||||||
.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)
|
||||||
@@ -41,7 +46,7 @@ struct LocationChannelsSheet: View {
|
|||||||
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("location permission denied. enable in settings to use location channels.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("open settings") { openSystemLocationSettings() }
|
Button("open settings") { openSystemLocationSettings() }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -59,29 +64,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.navigationBarHidden(true)
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
.toolbar {
|
.navigationTitle("")
|
||||||
ToolbarItem(placement: .automatic) {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -111,6 +96,16 @@ 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 {
|
List {
|
||||||
// Mesh option first (no bookmark)
|
// Mesh option first (no bookmark)
|
||||||
@@ -118,6 +113,7 @@ struct LocationChannelsSheet: View {
|
|||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
|
|
||||||
// Nearby options
|
// Nearby options
|
||||||
if !manager.availableChannels.isEmpty {
|
if !manager.availableChannels.isEmpty {
|
||||||
@@ -136,7 +132,7 @@ 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)
|
||||||
@@ -147,20 +143,22 @@ struct LocationChannelsSheet: View {
|
|||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("finding nearby channels…")
|
Text("finding nearby channels…")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom geohash teleport
|
// Custom geohash teleport
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Text("#")
|
Text("#")
|
||||||
.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)
|
||||||
@@ -168,7 +166,7 @@ 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
|
// Allow only geohash base32 characters, strip '#', limit length
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
@@ -196,14 +194,13 @@ struct LocationChannelsSheet: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("teleport")
|
Text("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)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.background(Color.secondary.opacity(0.12))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
@@ -212,16 +209,17 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
|
|
||||||
// Bookmarked geohashes
|
// Bookmarked geohashes
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
if !bookmarks.bookmarks.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("bookmarked")
|
Text("bookmarked")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||||
@@ -238,7 +236,7 @@ struct LocationChannelsSheet: View {
|
|||||||
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)
|
||||||
@@ -262,15 +260,18 @@ struct LocationChannelsSheet: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer action inside the list
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
|
torToggleSection
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openSystemLocationSettings()
|
openSystemLocationSettings()
|
||||||
}) {
|
}) {
|
||||||
Text("remove location access")
|
Text("remove location access")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -280,6 +281,7 @@ struct LocationChannelsSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
@@ -317,12 +319,12 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +335,7 @@ struct LocationChannelsSheet: View {
|
|||||||
return subtitlePrefix
|
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)
|
||||||
@@ -341,7 +343,7 @@ struct LocationChannelsSheet: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Text("✔︎")
|
Text("✔︎")
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
trailingAccessory()
|
trailingAccessory()
|
||||||
@@ -409,8 +411,36 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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("tor routing")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("hides your ip for location channels. recommended: on.")
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.12))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -419,6 +449,34 @@ extension LocationChannelsSheet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct IRCToggleStyle: ToggleStyle {
|
||||||
|
let accent: Color
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
Button(action: { configuration.isOn.toggle() }) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
configuration.label
|
||||||
|
Spacer()
|
||||||
|
Text(configuration.isOn ? "on" : "off")
|
||||||
|
.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 {
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LocationNotesSheet: View {
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
|
||||||
@Binding var notesGeohash: String?
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
|
|
||||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
|
||||||
private var textColor: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
|
||||||
private var secondaryTextColor: Color { textColor.opacity(0.8) }
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .building })?.geohash {
|
|
||||||
// Found block geohash: show notes view
|
|
||||||
LocationNotesView(geohash: gh)
|
|
||||||
.environmentObject(viewModel)
|
|
||||||
} else {
|
|
||||||
// Acquire location: keep a loading overlay (Matrix) until we either get a block geohash
|
|
||||||
ZStack {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("notes")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
Text("acquiring location…")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button(action: { dismiss() }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
|
||||||
.frame(height: 44)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.background(backgroundColor.opacity(0.95))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
MatrixRainView()
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.onAppear {
|
|
||||||
LocationChannelManager.shared.enableLocationChannels()
|
|
||||||
// Nudge a fresh fix
|
|
||||||
LocationChannelManager.shared.refreshChannels()
|
|
||||||
}
|
|
||||||
.onChange(of: locationManager.availableChannels) { channels in
|
|
||||||
if notesGeohash == nil, let building = channels.first(where: { $0.level == .building }) {
|
|
||||||
notesGeohash = building.geohash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ struct LocationNotesView: View {
|
|||||||
let onNotesCountChanged: ((Int) -> Void)?
|
let onNotesCountChanged: ((Int) -> Void)?
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var draft: String = ""
|
@State private var draft: String = ""
|
||||||
@@ -18,30 +19,24 @@ struct LocationNotesView: View {
|
|||||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
colorScheme == .dark ? Color.black : Color.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 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)
|
|
||||||
}
|
|
||||||
// Slightly darker green for hash suffix emphasis
|
|
||||||
private var darkerTextColor: Color {
|
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
ScrollView {
|
||||||
Divider()
|
VStack(spacing: 0) {
|
||||||
list
|
headerSection
|
||||||
Divider()
|
notesContent
|
||||||
input
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
inputSection
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.foregroundColor(textColor)
|
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
manager.setGeohash(newValue)
|
manager.setGeohash(newValue)
|
||||||
@@ -50,100 +45,202 @@ struct LocationNotesView: View {
|
|||||||
.onChange(of: manager.notes.count) { newValue in
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
onNotesCountChanged?(newValue)
|
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 header: some View {
|
private var closeButton: some View {
|
||||||
HStack {
|
Button(action: { dismiss() }) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Image(systemName: "xmark")
|
||||||
HStack(spacing: 4) {
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
let c = manager.notes.count
|
.frame(width: 32, height: 32)
|
||||||
Text("\(c) \(c == 1 ? "note" : "notes") ")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
Text("@ ")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
Text("#\(geohash)")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
|
|
||||||
Text(buildingName)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
|
|
||||||
Text(blockName)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button(action: { dismiss() }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 12)
|
.accessibilityLabel("Close")
|
||||||
.background(backgroundColor.opacity(0.95))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var headerSection: some View {
|
||||||
ScrollView {
|
let count = manager.notes.count
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(manager.notes) { note in
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text("#\(geohash) • \(count) \(count == 1 ? "note" : "notes")")
|
||||||
HStack(spacing: 6) {
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
// Show @name without the #abcd suffix; timestamp in brackets
|
Spacer()
|
||||||
HStack(spacing: 0) {
|
closeButton
|
||||||
Text("@")
|
}
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
if let building = locationManager.locationNames[.building], !building.isEmpty {
|
||||||
.foregroundColor(textColor)
|
Text(building)
|
||||||
let parts = splitSuffix(from: note.displayName)
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
Text(parts.0)
|
.foregroundColor(accentGreen)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
|
||||||
.foregroundColor(textColor)
|
Text(block)
|
||||||
}
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
let ts = timestampText(for: note.createdAt)
|
.foregroundColor(accentGreen)
|
||||||
Text(ts.isEmpty ? "" : "[\(ts)]")
|
}
|
||||||
.font(.system(size: 11, design: .monospaced))
|
Text("add short permanent notes to this location for other visitors to find.")
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
.foregroundColor(.secondary)
|
||||||
Text(note.content)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
if manager.state == .loading && !manager.initialLoadComplete {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Text("loading recent notes…")
|
||||||
}
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.padding(.horizontal, 12)
|
.foregroundColor(.secondary)
|
||||||
}
|
} else if manager.state == .noRelays {
|
||||||
|
Text("geo relays unavailable; notes paused")
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var input: some View {
|
private var notesContent: some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
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("@\(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("no geo relays nearby")
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
Text("notes rely on geo relays. check connection and try again.")
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button("retry") { manager.refresh() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("loading notes…")
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("no notes yet")
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
Text("be the first to add one for this spot.")
|
||||||
|
.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("dismiss") { manager.clearError() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputSection: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
TextField("add a note for this place", text: $draft, axis: .vertical)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
.padding(.horizontal, 12)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
Button(action: send) {
|
Button(action: send) {
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
|
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(!sendButtonEnabled)
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
}
|
||||||
.frame(minHeight: 44)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 14)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor)
|
||||||
|
.overlay(Divider(), alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
@@ -153,15 +250,17 @@ struct LocationNotesView: View {
|
|||||||
draft = ""
|
draft = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sendButtonEnabled: Bool {
|
||||||
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Timestamp Formatting
|
// MARK: - Timestamp Formatting
|
||||||
private func timestampText(for date: Date) -> String {
|
private func timestampText(for date: Date) -> String {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||||
// Relative (minute/hour/day), no seconds
|
|
||||||
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||||
return rel.isEmpty ? "" : "\(rel) ago"
|
return rel.isEmpty ? "" : "\(rel) ago"
|
||||||
} else {
|
} else {
|
||||||
// Absolute date (MMM d or MMM d, yyyy if different year)
|
|
||||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||||
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||||
return fmt.string(from: date)
|
return fmt.string(from: date)
|
||||||
@@ -189,16 +288,3 @@ struct LocationNotesView: View {
|
|||||||
return f
|
return f
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct MeshPeerList: View {
|
|||||||
if viewModel.allPeers.isEmpty {
|
if viewModel.allPeers.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.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)
|
||||||
@@ -45,27 +45,27 @@ struct MeshPeerList: View {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,19 +73,19 @@ struct MeshPeerList: View {
|
|||||||
let (base, suffix) = splitSuffix(from: displayName)
|
let (base, suffix) = splitSuffix(from: displayName)
|
||||||
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.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked")
|
.help("Blocked")
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ struct MeshPeerList: View {
|
|||||||
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 {
|
||||||
@@ -102,12 +102,12 @@ struct MeshPeerList: View {
|
|||||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
if let fp = viewModel.getFingerprint(for: peer.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,7 +118,7 @@ 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("New messages")
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ struct MeshPeerList: View {
|
|||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.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)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ struct MyQRView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("scan to verify me")
|
Text("scan to verify me")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
QRCodeImage(data: qrString, size: 240)
|
QRCodeImage(data: qrString, size: 240)
|
||||||
@@ -24,7 +24,7 @@ struct MyQRView: View {
|
|||||||
|
|
||||||
// Non-scrolling, fully visible URL (wraps across lines)
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
Text(qrString)
|
Text(qrString)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -60,7 +60,7 @@ struct QRCodeImage: View {
|
|||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(
|
.overlay(
|
||||||
Text("qr unavailable")
|
Text("qr unavailable")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ struct QRScanView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
#else
|
#else
|
||||||
Text("paste qr content to validate:")
|
Text("paste qr content to validate:")
|
||||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||||
TextEditor(text: $input)
|
TextEditor(text: $input)
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.border(Color.gray.opacity(0.4))
|
.border(Color.gray.opacity(0.4))
|
||||||
@@ -255,7 +255,7 @@ struct VerificationSheetView: View {
|
|||||||
// Top header (always at top)
|
// Top header (always at top)
|
||||||
HStack {
|
HStack {
|
||||||
Text("VERIFY")
|
Text("VERIFY")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -263,7 +263,7 @@ struct VerificationSheetView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -279,7 +279,7 @@ struct VerificationSheetView: View {
|
|||||||
if showingScanner {
|
if showingScanner {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("scan a friend's qr")
|
Text("scan a friend's qr")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
@@ -310,13 +310,13 @@ struct VerificationSheetView: View {
|
|||||||
if showingScanner {
|
if showingScanner {
|
||||||
Button(action: { showingScanner = false }) {
|
Button(action: { showingScanner = false }) {
|
||||||
Label("show my qr", systemImage: "qrcode")
|
Label("show my qr", systemImage: "qrcode")
|
||||||
.font(.system(size: 13, design: .monospaced))
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
} else {
|
} else {
|
||||||
Button(action: { showingScanner = true }) {
|
Button(action: { showingScanner = true }) {
|
||||||
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.gray)
|
.tint(.gray)
|
||||||
@@ -328,7 +328,7 @@ struct VerificationSheetView: View {
|
|||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
Label("remove verification", systemImage: "minus.circle")
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.gray)
|
.tint(.gray)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import UniformTypeIdentifiers
|
|||||||
/// Modern share extension using UIKit + UTTypes.
|
/// Modern share extension using UIKit + UTTypes.
|
||||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||||
final class ShareViewController: UIViewController {
|
final class ShareViewController: UIViewController {
|
||||||
|
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||||
|
private static let groupID = "group.chat.bitchat"
|
||||||
|
|
||||||
private let statusLabel: UILabel = {
|
private let statusLabel: UILabel = {
|
||||||
let l = UILabel()
|
let l = UILabel()
|
||||||
l.translatesAutoresizingMaskIntoConstraints = false
|
l.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -32,8 +35,9 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||||
])
|
])
|
||||||
|
DispatchQueue.global().async {
|
||||||
processShare()
|
self.processShare()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Processing
|
// MARK: - Processing
|
||||||
@@ -149,7 +153,7 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
|
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
||||||
userDefaults.set(content, forKey: "sharedContent")
|
userDefaults.set(content, forKey: "sharedContent")
|
||||||
userDefaults.set(type, forKey: "sharedContentType")
|
userDefaults.set(type, forKey: "sharedContentType")
|
||||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||||
@@ -160,7 +164,7 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.text = msg
|
statusLabel.text = msg
|
||||||
// Complete shortly after showing status
|
// Complete shortly after showing status
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
||||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
let expectation = XCTestExpectation(description: "Delivery handler called")
|
let expectation = XCTestExpectation(description: "Delivery handler called")
|
||||||
|
|
||||||
service.packetDeliveryHandler = { packet in
|
service.packetDeliveryHandler = { packet in
|
||||||
if let msg = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let msg = BitchatMessage(packet.payload) {
|
||||||
XCTAssertEqual(msg.content, "Test delivery")
|
XCTAssertEqual(msg.content, "Test delivery")
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ final class PrivateChatE2ETests: XCTestCase {
|
|||||||
alice.packetDeliveryHandler = { packet in
|
alice.packetDeliveryHandler = { packet in
|
||||||
// Encrypt outgoing private messages
|
// Encrypt outgoing private messages
|
||||||
if packet.type == 0x01,
|
if packet.type == 0x01,
|
||||||
let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
let message = BitchatMessage(packet.payload),
|
||||||
message.isPrivate {
|
message.isPrivate {
|
||||||
do {
|
do {
|
||||||
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
||||||
@@ -164,7 +164,7 @@ final class PrivateChatE2ETests: XCTestCase {
|
|||||||
if packet.type == 0x02 {
|
if packet.type == 0x02 {
|
||||||
do {
|
do {
|
||||||
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
||||||
if let message = BitchatMessage.fromBinaryPayload(decrypted) {
|
if let message = BitchatMessage(decrypted) {
|
||||||
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
||||||
XCTAssertTrue(message.isPrivate)
|
XCTAssertTrue(message.isPrivate)
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
// Set up relay in Bob
|
// Set up relay in Bob
|
||||||
bob.packetDeliveryHandler = { packet in
|
bob.packetDeliveryHandler = { packet in
|
||||||
// Bob should relay to Charlie
|
// Bob should relay to Charlie
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
if let message = BitchatMessage(packet.payload),
|
||||||
message.sender == TestConstants.testNickname1 {
|
message.sender == TestConstants.testNickname1 {
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
@@ -437,7 +437,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
// Check if should relay
|
// Check if should relay
|
||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class FontBitchatTests: XCTestCase {
|
||||||
|
func testMonospacedMapping() {
|
||||||
|
XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
|
||||||
|
XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
|
||||||
|
XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWeightIsPreserved() {
|
||||||
|
let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class GCSFilterTests: XCTestCase {
|
||||||
|
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||||
|
let id = Data(repeating: 0xAB, count: 16)
|
||||||
|
let ids = Array(repeating: id, count: 64)
|
||||||
|
|
||||||
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||||
|
XCTAssertGreaterThanOrEqual(params.m, 1)
|
||||||
|
|
||||||
|
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||||
|
XCTAssertLessThanOrEqual(decoded.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBucketAvoidsZeroCandidate() {
|
||||||
|
let id = Data(repeating: 0x01, count: 16)
|
||||||
|
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
||||||
|
XCTAssertNotEqual(bucket, 0)
|
||||||
|
XCTAssertLessThan(bucket, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class GossipSyncManagerTests: XCTestCase {
|
||||||
|
func testConcurrentPacketIntakeAndSyncRequest() {
|
||||||
|
let manager = GossipSyncManager(myPeerID: "0102030405060708")
|
||||||
|
let delegate = RecordingDelegate()
|
||||||
|
let sendExpectation = expectation(description: "sync request sent")
|
||||||
|
delegate.onSend = { sendExpectation.fulfill() }
|
||||||
|
manager.delegate = delegate
|
||||||
|
|
||||||
|
let iterations = 200
|
||||||
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
for i in 0..<iterations {
|
||||||
|
group.enter()
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: Data(hexString: "1122334455667788") ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: 1_000_000 + UInt64(i),
|
||||||
|
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
manager.onPublicPacketSeen(packet)
|
||||||
|
Thread.sleep(forTimeInterval: 0.001)
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
|
||||||
|
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
group.wait()
|
||||||
|
wait(for: [sendExpectation], timeout: 2.0)
|
||||||
|
|
||||||
|
guard let lastPacket = delegate.lastPacket else {
|
||||||
|
XCTFail("Expected sync packet to be sent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
||||||
|
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
|
var onSend: (() -> Void)?
|
||||||
|
private(set) var lastPacket: BitchatPacket?
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
|
lock.lock()
|
||||||
|
lastPacket = packet
|
||||||
|
lock.unlock()
|
||||||
|
onSend?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPacket(to peerID: String, packet: BitchatPacket) {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -531,7 +531,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
// Setup encryption at Alice
|
// Setup encryption at Alice
|
||||||
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x01,
|
if packet.type == 0x01,
|
||||||
let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
let message = BitchatMessage(packet.payload),
|
||||||
message.isPrivate && packet.recipientID != nil {
|
message.isPrivate && packet.recipientID != nil {
|
||||||
// Encrypt private messages
|
// Encrypt private messages
|
||||||
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
||||||
@@ -553,7 +553,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x02 {
|
if packet.type == 0x02 {
|
||||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
||||||
let message = BitchatMessage.fromBinaryPayload(decrypted) {
|
let message = BitchatMessage(decrypted) {
|
||||||
bobDecrypted = message.content == "Secret message"
|
bobDecrypted = message.content == "Secret message"
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
@@ -626,7 +626,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
node.packetDeliveryHandler = { packet in
|
node.packetDeliveryHandler = { packet in
|
||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesManagerTests: XCTestCase {
|
||||||
|
func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||||
|
var subscribeCalled = false
|
||||||
|
let deps = LocationNotesDependencies(
|
||||||
|
relayLookup: { _, _ in [] },
|
||||||
|
subscribe: { _, _, _, _, _ in
|
||||||
|
subscribeCalled = true
|
||||||
|
},
|
||||||
|
unsubscribe: { _ in },
|
||||||
|
sendEvent: { _, _ in },
|
||||||
|
deriveIdentity: { _ in fatalError("should not derive identity") },
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
|
||||||
|
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
||||||
|
|
||||||
|
XCTAssertFalse(subscribeCalled)
|
||||||
|
XCTAssertEqual(manager.state, .noRelays)
|
||||||
|
XCTAssertTrue(manager.initialLoadComplete)
|
||||||
|
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSendWhenNoRelaysSurfacesError() {
|
||||||
|
var sendCalled = false
|
||||||
|
let deps = LocationNotesDependencies(
|
||||||
|
relayLookup: { _, _ in [] },
|
||||||
|
subscribe: { _, _, _, _, _ in },
|
||||||
|
unsubscribe: { _ in },
|
||||||
|
sendEvent: { _, _ in sendCalled = true },
|
||||||
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
|
||||||
|
let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
||||||
|
manager.send(content: "hello", nickname: "tester")
|
||||||
|
|
||||||
|
XCTAssertFalse(sendCalled)
|
||||||
|
XCTAssertEqual(manager.state, .noRelays)
|
||||||
|
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
||||||
|
var relaysCaptured: [String] = []
|
||||||
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
|
var storedEOSE: (() -> Void)?
|
||||||
|
let deps = LocationNotesDependencies(
|
||||||
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||||
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
|
XCTAssertEqual(filter.kinds, [1])
|
||||||
|
XCTAssertFalse(id.isEmpty)
|
||||||
|
relaysCaptured = relays
|
||||||
|
storedHandler = handler
|
||||||
|
storedEOSE = eose
|
||||||
|
},
|
||||||
|
unsubscribe: { _ in },
|
||||||
|
sendEvent: { _, _ in },
|
||||||
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
|
||||||
|
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
||||||
|
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||||
|
XCTAssertEqual(manager.state, .loading)
|
||||||
|
|
||||||
|
var event = NostrEvent(
|
||||||
|
pubkey: "pub",
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: [["g", "abcd1234"]],
|
||||||
|
content: "hi"
|
||||||
|
)
|
||||||
|
event.id = "event1"
|
||||||
|
storedHandler?(event)
|
||||||
|
storedEOSE?()
|
||||||
|
|
||||||
|
XCTAssertEqual(manager.state, .ready)
|
||||||
|
XCTAssertEqual(manager.notes.count, 1)
|
||||||
|
XCTAssertEqual(manager.notes.first?.content, "hi")
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestError: Error {
|
||||||
|
case shouldNotDerive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesCounterTests: XCTestCase {
|
||||||
|
func testSubscribeWithoutRelaysMarksUnavailable() {
|
||||||
|
var subscribeCalled = false
|
||||||
|
let deps = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { _, _ in [] },
|
||||||
|
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
||||||
|
unsubscribe: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
|
counter.subscribe(geohash: "abcdefgh")
|
||||||
|
|
||||||
|
XCTAssertFalse(subscribeCalled)
|
||||||
|
XCTAssertFalse(counter.relayAvailable)
|
||||||
|
XCTAssertTrue(counter.initialLoadComplete)
|
||||||
|
XCTAssertEqual(counter.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeCountsUniqueNotes() {
|
||||||
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
|
var storedEOSE: (() -> Void)?
|
||||||
|
let deps = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { _, _ in ["wss://relay.geo"] },
|
||||||
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
|
XCTAssertEqual(relays, ["wss://relay.geo"])
|
||||||
|
XCTAssertEqual(filter.kinds, [1])
|
||||||
|
XCTAssertFalse(id.isEmpty)
|
||||||
|
storedHandler = handler
|
||||||
|
storedEOSE = eose
|
||||||
|
},
|
||||||
|
unsubscribe: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
|
counter.subscribe(geohash: "abcdefgh")
|
||||||
|
|
||||||
|
var first = NostrEvent(
|
||||||
|
pubkey: "pub",
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: [["g", "abcdefgh"]],
|
||||||
|
content: "a"
|
||||||
|
)
|
||||||
|
first.id = "eventA"
|
||||||
|
storedHandler?(first)
|
||||||
|
|
||||||
|
let duplicate = first
|
||||||
|
storedHandler?(duplicate)
|
||||||
|
|
||||||
|
storedEOSE?()
|
||||||
|
|
||||||
|
XCTAssertTrue(counter.relayAvailable)
|
||||||
|
XCTAssertEqual(counter.count, 1)
|
||||||
|
XCTAssertTrue(counter.initialLoadComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -309,7 +309,7 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
func simulateIncomingPacket(_ packet: BitchatPacket) {
|
func simulateIncomingPacket(_ packet: BitchatPacket) {
|
||||||
// Process through the actual handling logic
|
// Process through the actual handling logic
|
||||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
var shouldDeliver = false
|
var shouldDeliver = false
|
||||||
seenLock.lock()
|
seenLock.lock()
|
||||||
if !seenMessageIDs.contains(message.id) {
|
if !seenMessageIDs.contains(message.id) {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class NotificationStreamAssemblerTests: XCTestCase {
|
||||||
|
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
||||||
|
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
|
||||||
|
return BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: sender,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: Data([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssemblesSingleFrameAcrossChunks() {
|
||||||
|
var assembler = NotificationStreamAssembler()
|
||||||
|
let packet = makePacket()
|
||||||
|
guard let frame = packet.toBinaryData(padding: false) else {
|
||||||
|
return XCTFail("Failed to encode packet")
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(BinaryProtocol.decode(frame))
|
||||||
|
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
|
||||||
|
XCTAssertEqual(payloadLen, packet.payload.count)
|
||||||
|
|
||||||
|
let splitIndex = min(20, max(1, frame.count / 2))
|
||||||
|
let first = frame.prefix(splitIndex)
|
||||||
|
let second = frame.suffix(from: splitIndex)
|
||||||
|
XCTAssertEqual(first.count + second.count, frame.count)
|
||||||
|
|
||||||
|
var result = assembler.append(first)
|
||||||
|
XCTAssertTrue(result.frames.isEmpty)
|
||||||
|
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
||||||
|
XCTAssertFalse(result.reset)
|
||||||
|
|
||||||
|
result = assembler.append(second)
|
||||||
|
XCTAssertEqual(result.frames.count, 1)
|
||||||
|
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
||||||
|
XCTAssertFalse(result.reset)
|
||||||
|
|
||||||
|
guard let frameData = result.frames.first else {
|
||||||
|
return XCTFail("Missing frame data")
|
||||||
|
}
|
||||||
|
if frameData.count != frame.count {
|
||||||
|
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let decoded = BinaryProtocol.decode(frameData) else {
|
||||||
|
return XCTFail("Failed to decode frame")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(decoded.type, packet.type)
|
||||||
|
XCTAssertEqual(decoded.payload, packet.payload)
|
||||||
|
XCTAssertEqual(decoded.senderID, packet.senderID)
|
||||||
|
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
||||||
|
|
||||||
|
var directAssembler = NotificationStreamAssembler()
|
||||||
|
let directResult = directAssembler.append(frame)
|
||||||
|
XCTAssertEqual(directResult.frames.first?.count, frame.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssemblesMultipleFramesSequentially() {
|
||||||
|
var assembler = NotificationStreamAssembler()
|
||||||
|
let packet1 = makePacket(timestamp: 0xABC)
|
||||||
|
let packet2 = makePacket(timestamp: 0xDEF)
|
||||||
|
|
||||||
|
guard let frame1 = packet1.toBinaryData(padding: false),
|
||||||
|
let frame2 = packet2.toBinaryData(padding: false) else {
|
||||||
|
return XCTFail("Failed to encode packets")
|
||||||
|
}
|
||||||
|
|
||||||
|
var combined = Data()
|
||||||
|
combined.append(frame1)
|
||||||
|
combined.append(frame2)
|
||||||
|
let firstChunk = combined.prefix(20)
|
||||||
|
let secondChunk = combined.suffix(from: 20)
|
||||||
|
|
||||||
|
var result = assembler.append(firstChunk)
|
||||||
|
XCTAssertTrue(result.frames.isEmpty)
|
||||||
|
|
||||||
|
result = assembler.append(secondChunk)
|
||||||
|
XCTAssertEqual(result.frames.count, 2)
|
||||||
|
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
|
||||||
|
let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
|
||||||
|
return XCTFail("Failed to decode frames")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
|
||||||
|
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDropsInvalidPrefixByte() {
|
||||||
|
var assembler = NotificationStreamAssembler()
|
||||||
|
let packet = makePacket(timestamp: 0xF00)
|
||||||
|
guard let frame = packet.toBinaryData(padding: false) else {
|
||||||
|
return XCTFail("Failed to encode packet")
|
||||||
|
}
|
||||||
|
var noisyFrame = Data([0x00])
|
||||||
|
noisyFrame.append(frame)
|
||||||
|
|
||||||
|
let result = assembler.append(noisyFrame)
|
||||||
|
XCTAssertEqual(result.droppedPrefixes, [0x00])
|
||||||
|
XCTAssertEqual(result.frames.count, 1)
|
||||||
|
XCTAssertFalse(result.reset)
|
||||||
|
|
||||||
|
guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
|
||||||
|
return XCTFail("Failed to decode frame after drop")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,7 +197,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
guard let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to decode message from binary")
|
XCTFail("Failed to decode message from binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to encode/decode private message")
|
XCTFail("Failed to encode/decode private message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let message = TestHelpers.createTestMessage(mentions: mentions)
|
let message = TestHelpers.createTestMessage(mentions: mentions)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to encode/decode message with mentions")
|
XCTFail("Failed to encode/decode message with mentions")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to encode/decode relay message")
|
XCTFail("Failed to encode/decode relay message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let message = TestHelpers.createTestMessage(content: largeContent)
|
let message = TestHelpers.createTestMessage(content: largeContent)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to handle large message")
|
XCTFail("Failed to handle large message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,7 +307,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
||||||
|
|
||||||
guard let payload = emptyMessage.toBinaryPayload(),
|
guard let payload = emptyMessage.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
let decodedMessage = BitchatMessage(payload) else {
|
||||||
XCTFail("Failed to handle empty message")
|
XCTFail("Failed to handle empty message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "BitLogger",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v16),
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "BitLogger",
|
||||||
|
targets: ["BitLogger"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "BitLogger",
|
||||||
|
path: "Sources"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// OSLog+Categories.swift
|
// OSLog+Categories.swift
|
||||||
// bitchat
|
// BitLogger
|
||||||
//
|
//
|
||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
extension OSLog {
|
public extension OSLog {
|
||||||
private static let subsystem = "chat.bitchat"
|
private static let subsystem = "chat.bitchat"
|
||||||
|
|
||||||
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
static let noise = OSLog(subsystem: subsystem, category: "noise")
|
||||||
+41
-73
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// SecureLogger.swift
|
// SecureLogger.swift
|
||||||
// bitchat
|
// BitLogger
|
||||||
//
|
//
|
||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
@@ -11,7 +11,7 @@ import os.log
|
|||||||
|
|
||||||
/// Centralized security-aware logging framework
|
/// Centralized security-aware logging framework
|
||||||
/// Provides safe logging that filters sensitive data and security events
|
/// Provides safe logging that filters sensitive data and security events
|
||||||
final class SecureLogger {
|
public final class SecureLogger {
|
||||||
|
|
||||||
// MARK: - Timestamp Formatter
|
// MARK: - Timestamp Formatter
|
||||||
|
|
||||||
@@ -85,40 +85,50 @@ final class SecureLogger {
|
|||||||
private static func shouldLog(_ level: LogLevel) -> Bool {
|
private static func shouldLog(_ level: LogLevel) -> Bool {
|
||||||
return level.order >= minimumLevel.order
|
return level.order >= minimumLevel.order
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Logging Methods
|
||||||
|
|
||||||
|
public extension SecureLogger {
|
||||||
|
|
||||||
// MARK: - In-memory debug buffer (DEBUG builds only)
|
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
#if DEBUG
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
private static let logBufferQueue = DispatchQueue(label: "chat.bitchat.securelogger.buffer")
|
log(message(), category: category, level: .debug, file: file, line: line, function: function)
|
||||||
private static var logBuffer: [String] = []
|
|
||||||
private static let logBufferCap: Int = 2000
|
|
||||||
|
|
||||||
/// Append a sanitized line to the in-memory debug buffer
|
|
||||||
private static func record(_ line: String) {
|
|
||||||
logBufferQueue.async {
|
|
||||||
logBuffer.append(line)
|
|
||||||
if logBuffer.count > logBufferCap {
|
|
||||||
logBuffer.removeFirst(logBuffer.count - logBufferCap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current logs as an array (oldest first)
|
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
static func getLogs() -> [String] {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
return logBufferQueue.sync { logBuffer }
|
log(message(), category: category, level: .info, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current logs as a single string
|
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
static func getLogText() -> String {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
return getLogs().joined(separator: "\n")
|
log(message(), category: category, level: .warning, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the in-memory debug buffer
|
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
static func clearLogs() {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
logBufferQueue.async { logBuffer.removeAll(keepingCapacity: false) }
|
log(message(), category: category, level: .error, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Security Event Types
|
/// Log errors with context
|
||||||
|
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
|
let sanitized = sanitize(context())
|
||||||
|
let errorDesc = sanitize(error.localizedDescription)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
|
#else
|
||||||
|
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Security Event Logging
|
||||||
|
|
||||||
|
public extension SecureLogger {
|
||||||
|
|
||||||
enum SecurityEvent {
|
enum SecurityEvent {
|
||||||
case handshakeStarted(peerID: String)
|
case handshakeStarted(peerID: String)
|
||||||
@@ -143,30 +153,6 @@ final class SecureLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Logging Methods
|
|
||||||
|
|
||||||
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .debug, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .info, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .warning, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
log(message(), category: category, level: .error, file: file, line: line, function: function)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Security Event Logging
|
|
||||||
|
|
||||||
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
|
logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
@@ -182,27 +168,11 @@ final class SecureLogger {
|
|||||||
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
logSecurityEvent(event, level: .error, file: file, line: line, function: function)
|
logSecurityEvent(event, level: .error, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log errors with context
|
|
||||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
|
||||||
let sanitized = sanitize(context())
|
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
let line = "\(location) Error: \(sanitized) — \(errorDesc)"
|
|
||||||
os_log("%{public}@", log: category, type: .error, line)
|
|
||||||
record(line)
|
|
||||||
#else
|
|
||||||
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Extensions
|
// MARK: - Convenience Extensions
|
||||||
|
|
||||||
extension SecureLogger {
|
public extension SecureLogger {
|
||||||
|
|
||||||
enum KeyOperation: String, CustomStringConvertible {
|
enum KeyOperation: String, CustomStringConvertible {
|
||||||
case load
|
case load
|
||||||
@@ -211,7 +181,7 @@ extension SecureLogger {
|
|||||||
case delete
|
case delete
|
||||||
case save
|
case save
|
||||||
|
|
||||||
var description: String { rawValue }
|
public var description: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log key management operations
|
/// Log key management operations
|
||||||
@@ -237,7 +207,6 @@ private extension SecureLogger {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
record(sanitized)
|
|
||||||
#else
|
#else
|
||||||
// In release builds, only log non-debug messages
|
// In release builds, only log non-debug messages
|
||||||
if level != .debug {
|
if level != .debug {
|
||||||
@@ -255,7 +224,6 @@ private extension SecureLogger {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: .security, type: level.osLogType, message)
|
os_log("%{public}@", log: .security, type: level.osLogType, message)
|
||||||
record(message)
|
|
||||||
#else
|
#else
|
||||||
// In release, use private logging to prevent sensitive data exposure
|
// In release, use private logging to prevent sensitive data exposure
|
||||||
os_log("%{private}@", log: .security, type: level.osLogType, message)
|
os_log("%{private}@", log: .security, type: level.osLogType, message)
|
||||||
@@ -326,8 +294,8 @@ private extension SecureLogger {
|
|||||||
|
|
||||||
/// Helper to migrate from print statements to SecureLogger
|
/// Helper to migrate from print statements to SecureLogger
|
||||||
/// Usage: Replace print(...) with secureLog(...)
|
/// Usage: Replace print(...) with secureLog(...)
|
||||||
func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
|
public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let message = items.map { String(describing: $0) }.joined(separator: separator)
|
let message = items.map { String(describing: $0) }.joined(separator: separator)
|
||||||
SecureLogger.debug(message, file: file, line: line, function: function)
|
SecureLogger.debug(message, file: file, line: line, function: function)
|
||||||
-236
@@ -1,236 +0,0 @@
|
|||||||
name: bitchat
|
|
||||||
options:
|
|
||||||
bundleIdPrefix: chat.bitchat
|
|
||||||
deploymentTarget:
|
|
||||||
iOS: 16.0
|
|
||||||
macOS: 13.0
|
|
||||||
createIntermediateGroups: true
|
|
||||||
|
|
||||||
settings:
|
|
||||||
MARKETING_VERSION: 1.0.0
|
|
||||||
CURRENT_PROJECT_VERSION: 1
|
|
||||||
|
|
||||||
packages:
|
|
||||||
P256K:
|
|
||||||
url: https://github.com/21-DOT-DEV/swift-secp256k1
|
|
||||||
majorVersion: 0.21.1
|
|
||||||
|
|
||||||
targets:
|
|
||||||
bitchat_iOS:
|
|
||||||
type: application
|
|
||||||
platform: iOS
|
|
||||||
sources:
|
|
||||||
- bitchat
|
|
||||||
resources:
|
|
||||||
- bitchat/Assets.xcassets
|
|
||||||
- bitchat/LaunchScreen.storyboard
|
|
||||||
info:
|
|
||||||
path: bitchat/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleDisplayName: bitchat
|
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
|
||||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
|
||||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
|
||||||
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
|
|
||||||
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
|
|
||||||
UIBackgroundModes:
|
|
||||||
- bluetooth-central
|
|
||||||
- bluetooth-peripheral
|
|
||||||
UILaunchStoryboardName: LaunchScreen
|
|
||||||
UISupportedInterfaceOrientations:
|
|
||||||
- UIInterfaceOrientationPortrait
|
|
||||||
UISupportedInterfaceOrientations~ipad:
|
|
||||||
- UIInterfaceOrientationPortrait
|
|
||||||
- UIInterfaceOrientationPortraitUpsideDown
|
|
||||||
- UIInterfaceOrientationLandscapeLeft
|
|
||||||
- UIInterfaceOrientationLandscapeRight
|
|
||||||
UIRequiresFullScreen: false
|
|
||||||
CFBundleURLTypes:
|
|
||||||
- CFBundleURLSchemes:
|
|
||||||
- bitchat
|
|
||||||
# xcodegen quirk: include some macOS properties in iOS target
|
|
||||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
|
|
||||||
PRODUCT_NAME: bitchat
|
|
||||||
INFOPLIST_FILE: bitchat/Info.plist
|
|
||||||
ENABLE_PREVIEWS: YES
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
|
||||||
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat.entitlements
|
|
||||||
dependencies:
|
|
||||||
- target: bitchatShareExtension
|
|
||||||
embed: true
|
|
||||||
- package: P256K
|
|
||||||
- framework: Frameworks/tor-nolzma.xcframework
|
|
||||||
embed: true
|
|
||||||
codeSign: true
|
|
||||||
- sdk: libz.tbd
|
|
||||||
|
|
||||||
bitchat_macOS:
|
|
||||||
type: application
|
|
||||||
platform: macOS
|
|
||||||
sources:
|
|
||||||
- bitchat
|
|
||||||
resources:
|
|
||||||
- bitchat/Assets.xcassets
|
|
||||||
info:
|
|
||||||
path: bitchat/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleDisplayName: bitchat
|
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
|
||||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
|
||||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
|
||||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
|
||||||
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
|
|
||||||
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
|
|
||||||
CFBundleURLTypes:
|
|
||||||
- CFBundleURLSchemes:
|
|
||||||
- bitchat
|
|
||||||
# xcodegen quirk: include some iOS properties in macOS target
|
|
||||||
UIBackgroundModes:
|
|
||||||
- bluetooth-central
|
|
||||||
- bluetooth-peripheral
|
|
||||||
UILaunchStoryboardName: LaunchScreen
|
|
||||||
UISupportedInterfaceOrientations:
|
|
||||||
- UIInterfaceOrientationPortrait
|
|
||||||
UIRequiresFullScreen: true
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
|
|
||||||
PRODUCT_NAME: bitchat
|
|
||||||
INFOPLIST_FILE: bitchat/Info.plist
|
|
||||||
ENABLE_PREVIEWS: NO
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
|
||||||
CODE_SIGN_ENTITLEMENTS: bitchat/bitchat-macOS.entitlements
|
|
||||||
dependencies:
|
|
||||||
- package: P256K
|
|
||||||
- framework: Frameworks/tor-nolzma.xcframework
|
|
||||||
embed: true
|
|
||||||
codeSign: true
|
|
||||||
- sdk: libz.tbd
|
|
||||||
|
|
||||||
bitchatShareExtension:
|
|
||||||
type: app-extension
|
|
||||||
platform: iOS
|
|
||||||
sources:
|
|
||||||
- bitchatShareExtension
|
|
||||||
- bitchat/Services/TransportConfig.swift
|
|
||||||
info:
|
|
||||||
path: bitchatShareExtension/Info.plist
|
|
||||||
properties:
|
|
||||||
CFBundleDisplayName: bitchat
|
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
|
||||||
NSExtension:
|
|
||||||
NSExtensionPointIdentifier: com.apple.share-services
|
|
||||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
|
|
||||||
NSExtensionAttributes:
|
|
||||||
NSExtensionActivationRule:
|
|
||||||
NSExtensionActivationSupportsText: true
|
|
||||||
NSExtensionActivationSupportsWebURLWithMaxCount: 1
|
|
||||||
NSExtensionActivationSupportsImageWithMaxCount: 1
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.ShareExtension
|
|
||||||
INFOPLIST_FILE: bitchatShareExtension/Info.plist
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
CODE_SIGN_ENTITLEMENTS: bitchatShareExtension/bitchatShareExtension.entitlements
|
|
||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
|
|
||||||
|
|
||||||
bitchatTests_iOS:
|
|
||||||
type: bundle.unit-test
|
|
||||||
platform: iOS
|
|
||||||
sources:
|
|
||||||
- bitchatTests
|
|
||||||
dependencies:
|
|
||||||
- target: bitchat_iOS
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
|
|
||||||
INFOPLIST_FILE: bitchatTests/Info.plist
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
|
||||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bitchat
|
|
||||||
BUNDLE_LOADER: $(TEST_HOST)
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
|
|
||||||
bitchatTests_macOS:
|
|
||||||
type: bundle.unit-test
|
|
||||||
platform: macOS
|
|
||||||
sources:
|
|
||||||
- bitchatTests
|
|
||||||
dependencies:
|
|
||||||
- target: bitchat_macOS
|
|
||||||
settings:
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
|
|
||||||
INFOPLIST_FILE: bitchatTests/Info.plist
|
|
||||||
SWIFT_VERSION: 5.0
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
|
||||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat
|
|
||||||
BUNDLE_LOADER: $(TEST_HOST)
|
|
||||||
CODE_SIGN_STYLE: Automatic
|
|
||||||
CODE_SIGNING_REQUIRED: YES
|
|
||||||
CODE_SIGNING_ALLOWED: YES
|
|
||||||
DEVELOPMENT_TEAM: L3N5LHJD5Y
|
|
||||||
|
|
||||||
schemes:
|
|
||||||
bitchat (iOS):
|
|
||||||
build:
|
|
||||||
targets:
|
|
||||||
bitchat_iOS: all
|
|
||||||
bitchatShareExtension: all
|
|
||||||
run:
|
|
||||||
config: Debug
|
|
||||||
executable: bitchat_iOS
|
|
||||||
test:
|
|
||||||
config: Debug
|
|
||||||
targets:
|
|
||||||
- bitchatTests_iOS
|
|
||||||
profile:
|
|
||||||
config: Release
|
|
||||||
executable: bitchat_iOS
|
|
||||||
analyze:
|
|
||||||
config: Debug
|
|
||||||
archive:
|
|
||||||
config: Release
|
|
||||||
|
|
||||||
bitchat (macOS):
|
|
||||||
build:
|
|
||||||
targets:
|
|
||||||
bitchat_macOS: all
|
|
||||||
run:
|
|
||||||
config: Debug
|
|
||||||
executable: bitchat_macOS
|
|
||||||
test:
|
|
||||||
config: Debug
|
|
||||||
targets:
|
|
||||||
- bitchatTests_macOS
|
|
||||||
profile:
|
|
||||||
config: Release
|
|
||||||
executable: bitchat_macOS
|
|
||||||
analyze:
|
|
||||||
config: Debug
|
|
||||||
archive:
|
|
||||||
config: Release
|
|
||||||
Vendored
+272
-290
@@ -1,293 +1,275 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
relay.damus.io,37.7621,-122.3971
|
nostr.tac.lol,47.4748,-122.273
|
||||||
nostr-pub.wellorder.net,45.5229,-122.9898
|
relay.javi.space,43.4633,11.8796
|
||||||
nostr.mom,50.4779,12.3713
|
nostr.einundzwanzig.space,50.1109,8.68213
|
||||||
nostr.slothy.win,37.7621,-122.3971
|
nostr.kalf.org,52.3676,4.90414
|
||||||
nostr.einundzwanzig.space,50.1155,8.6842
|
wot.dtonon.com,43.6532,-79.3832
|
||||||
nos.lol,50.4779,12.3713
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
relay.nostr.band,60.1695,24.9354
|
wot.basspistol.org,49.4521,11.0767
|
||||||
no.str.cr,9.9339,-84.0849
|
relay.puresignal.news,43.6532,-79.3832
|
||||||
nostr.massmux.com,50.1155,8.6842
|
nostrelites.org,41.8781,-87.6298
|
||||||
nostr-relay.schnitzel.world,39.0437,-77.4875
|
nostr-relay.nextblockvending.com,47.674,-122.122
|
||||||
relay.nostr.com.au,37.7621,-122.3971
|
|
||||||
knostr.neutrine.com,48.8534,2.3488
|
|
||||||
nostr.nodeofsven.com,47.4875,8.2965
|
|
||||||
nostr.vulpem.com,49.4542,11.0775
|
|
||||||
nostr-verif.slothy.win,37.7621,-122.3971
|
|
||||||
relay.lexingtonbitcoin.org,37.7621,-122.3971
|
|
||||||
nostr-1.nbo.angani.co,-1.2615,36.7903
|
|
||||||
relay.wellorder.net,45.5229,-122.9898
|
|
||||||
nostr.easydns.ca,43.7064,-79.3986
|
|
||||||
relay.dwadziesciajeden.pl,52.2298,21.0118
|
|
||||||
nostr.data.haus,50.4779,12.3713
|
|
||||||
nostr.einundzwanzig.space,50.1155,8.6842
|
|
||||||
nostr.mom,50.4779,12.3713
|
|
||||||
nos.lol,50.4779,12.3713
|
|
||||||
relay.nostr.band,60.1695,24.9354
|
|
||||||
nostr.massmux.com,50.1155,8.6842
|
|
||||||
nostr.vulpem.com,49.4542,11.0775
|
|
||||||
relay.damus.io,37.7621,-122.3971
|
|
||||||
nostr-pub.wellorder.net,45.5229,-122.9898
|
|
||||||
no.str.cr,9.9339,-84.0849
|
|
||||||
relay.dwadziesciajeden.pl,52.2298,21.0118
|
|
||||||
nostr.data.haus,50.4779,12.3713
|
|
||||||
relay.wellorder.net,45.5229,-122.9898
|
|
||||||
relay.nostromo.social,49.4542,11.0775
|
|
||||||
offchain.pub,34.0522,-118.2437
|
|
||||||
relay.nostr.wirednet.jp,35.9356,139.3044
|
|
||||||
relay.nostrcheck.me,37.7621,-122.3971
|
|
||||||
nostrue.com,40.8043,-74.0121
|
|
||||||
nostr-relay.schnitzel.world,39.0437,-77.4875
|
|
||||||
nproxy.kristapsk.lv,60.1695,24.9354
|
|
||||||
nostr.spaceshell.xyz,37.7621,-122.3971
|
|
||||||
nostr-dev.wellorder.net,45.5229,-122.9898
|
|
||||||
nostr-verified.wellorder.net,45.5229,-122.9898
|
|
||||||
nostr.roundrockbitcoiners.com,40.8043,-74.0121
|
|
||||||
slick.mjex.me,39.0437,-77.4875
|
|
||||||
nostr.yael.at,52.3740,4.8897
|
|
||||||
relay.primal.net,37.7621,-122.3971
|
|
||||||
nostr.oxtr.dev,50.4779,12.3713
|
|
||||||
nostr.21crypto.ch,46.5160,6.6328
|
|
||||||
nostr.liberty.fans,38.8003,-90.6265
|
|
||||||
nostr-02.dorafactory.org,1.2897,103.8501
|
|
||||||
relay.hodl.ar,-32.9468,-60.6393
|
|
||||||
nostr.middling.mydns.jp,35.8089,140.1185
|
|
||||||
nostr.namek.link,37.7621,-122.3971
|
|
||||||
nostrja-kari.heguro.com,37.7621,-122.3971
|
|
||||||
nostr.hifish.org,47.3667,8.5500
|
|
||||||
nostr.rikmeijer.nl,50.4779,12.3713
|
|
||||||
black.nostrcity.club,41.8119,-87.6873
|
|
||||||
nostr.hekster.org,37.3924,-121.9623
|
|
||||||
relay.wavlake.com,41.2619,-95.8608
|
|
||||||
nostr.sagaciousd.com,49.2497,-123.1193
|
|
||||||
nostr.fbxl.net,43.7064,-79.3986
|
|
||||||
ithurtswhenip.ee,50.7990,-1.0913
|
|
||||||
relay2.nostrchat.io,49.4542,11.0775
|
|
||||||
relay1.nostrchat.io,60.1695,24.9354
|
|
||||||
nostr-01.yakihonne.com,1.3215,103.6957
|
|
||||||
nostr.sathoarder.com,48.5839,7.7455
|
|
||||||
nostr.overmind.lol,37.7621,-122.3971
|
|
||||||
relay.verified-nostr.com,37.7621,-122.3971
|
|
||||||
purplerelay.com,50.1155,8.6842
|
|
||||||
relay.orangepill.ovh,49.0127,1.9694
|
|
||||||
nostr-relay.psfoundation.info,39.0437,-77.4875
|
|
||||||
soloco.nl,37.7621,-122.3971
|
|
||||||
relay.froth.zone,60.1695,24.9354
|
|
||||||
nostr.stakey.net,52.5250,5.7181
|
|
||||||
nostr.2b9t.xyz,34.0522,-118.2437
|
|
||||||
pyramid.fiatjaf.com,50.1155,8.6842
|
|
||||||
a.nos.lol,50.4779,12.3713
|
|
||||||
relay.magiccity.live,25.8130,-80.2320
|
|
||||||
nostr.notribe.net,40.8344,-74.1377
|
|
||||||
freelay.sovbit.host,64.1355,-21.8954
|
|
||||||
relay.credenso.cafe,43.4254,-80.5112
|
|
||||||
nostr.huszonegy.world,47.4984,19.0404
|
|
||||||
multiplexer.huszonegy.world,47.4984,19.0404
|
|
||||||
bucket.coracle.social,37.7621,-122.3971
|
|
||||||
nostr.kungfu-g.rip,33.7865,-84.4454
|
|
||||||
relay.artx.market,43.7064,-79.3986
|
|
||||||
relay.notoshi.win,13.3622,100.9835
|
|
||||||
vitor.nostr1.com,40.7143,-74.0060
|
|
||||||
nostr-02.yakihonne.com,1.3215,103.6957
|
|
||||||
nostr-03.dorafactory.org,1.2897,103.8501
|
|
||||||
n.ok0.org,-36.8485,174.7635
|
|
||||||
nostr.0x7e.xyz,47.5056,8.7241
|
|
||||||
relay.nostr.net,50.4779,12.3713
|
|
||||||
strfry.openhoofd.nl,51.5717,3.7042
|
|
||||||
relay.fountain.fm,39.0997,-94.5786
|
|
||||||
relay.usefusion.ai,38.7135,-78.1594
|
|
||||||
relay.varke.eu,52.6958,6.1944
|
|
||||||
nostr.satstralia.com,64.1355,-21.8954
|
|
||||||
relay.13room.space,37.7621,-122.3971
|
|
||||||
nostr.myshosholoza.co.za,52.3710,4.9042
|
|
||||||
nostr.carroarmato0.be,50.8517,3.6089
|
|
||||||
nostr.dbtc.link,37.7621,-122.3971
|
|
||||||
orangepiller.org,60.1695,24.9354
|
|
||||||
adre.su,59.9386,30.3141
|
|
||||||
relay.sincensura.org,37.7621,-122.3971
|
|
||||||
relay.freeplace.nl,52.3740,4.8897
|
|
||||||
bostr.bitcointxoko.com,64.1355,-21.8954
|
|
||||||
nostr.plantroon.com,50.1025,8.6299
|
|
||||||
srtrelay.c-stellar.net,37.7621,-122.3971
|
|
||||||
nostr.jfischer.org,49.4453,11.0222
|
|
||||||
nostr.novacisko.cz,52.2298,21.0118
|
|
||||||
relay.lumina.rocks,49.4453,11.0222
|
|
||||||
nostr.tavux.tech,50.9519,1.8563
|
|
||||||
relay.nostrhub.fr,50.1155,8.6842
|
|
||||||
relay.agorist.space,52.3740,4.8897
|
|
||||||
chorus.pjv.me,45.5229,-122.9898
|
|
||||||
relay.cosmicbolt.net,37.3924,-121.9623
|
|
||||||
santo.iguanatech.net,40.8344,-74.1377
|
|
||||||
relay.tagayasu.xyz,45.4112,-75.6981
|
|
||||||
relay.mostro.network,40.8344,-74.1377
|
|
||||||
relay.zone667.com,60.1695,24.9354
|
|
||||||
relay5.bitransfer.org,37.7621,-122.3971
|
|
||||||
relay.illuminodes.com,47.6062,-122.3321
|
|
||||||
relay2.angor.io,50.1155,8.6842
|
|
||||||
relay.satsdays.com,1.2897,103.8501
|
|
||||||
relay.angor.io,50.1155,8.6842
|
|
||||||
orangesync.tech,50.9333,6.9500
|
|
||||||
nostr-relay.cbrx.io,37.7621,-122.3971
|
|
||||||
relay.21e6.cz,50.0880,14.4208
|
|
||||||
nostr.chaima.info,50.1155,8.6842
|
|
||||||
relay.satlantis.io,32.8546,-79.9748
|
|
||||||
relay.digitalezukunft.cyou,45.5088,-73.5878
|
|
||||||
relay.tapestry.ninja,40.8043,-74.0121
|
|
||||||
relay.minibolt.info,37.7621,-122.3971
|
|
||||||
nostr.bilthon.dev,25.8130,-80.2320
|
|
||||||
nostr.makibisskey.work,37.7621,-122.3971
|
|
||||||
relay.mattybs.lol,37.7621,-122.3971
|
|
||||||
noxir.kpherox.dev,34.8436,135.5084
|
|
||||||
sendit.nosflare.com,37.7621,-122.3971
|
|
||||||
relay.coinos.io,37.7621,-122.3971
|
|
||||||
relay.nostraddress.com,37.7621,-122.3971
|
|
||||||
wot.nostr.party,36.1659,-86.7844
|
|
||||||
nostrelites.org,41.8500,-87.6500
|
|
||||||
relay.nostriot.com,43.7064,-79.3986
|
|
||||||
prl.plus,55.7522,37.6156
|
|
||||||
zap.watch,45.5088,-73.5878
|
|
||||||
wot.codingarena.top,50.4779,12.3713
|
|
||||||
nostr.azzamo.net,52.2284,21.0522
|
|
||||||
wot.sudocarlos.com,43.7064,-79.3986
|
|
||||||
relay.lnfi.network,45.6241,8.7851
|
|
||||||
wot.nostr.net,37.7621,-122.3971
|
|
||||||
relay.nostrdice.com,-33.8678,151.2073
|
|
||||||
wot.sebastix.social,51.3700,6.1681
|
|
||||||
wheat.happytavern.co,37.7621,-122.3971
|
|
||||||
relay.sigit.io,50.4779,12.3713
|
|
||||||
strfry.bonsai.com,37.8716,-122.2728
|
|
||||||
travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873
|
|
||||||
satsage.xyz,37.3924,-121.9623
|
|
||||||
relay.degmods.com,50.4779,12.3713
|
|
||||||
nostr.community.ath.cx,45.5088,-73.5878
|
|
||||||
nostr.coincrowd.fund,39.0437,-77.4875
|
|
||||||
strfry.shock.network,41.8847,-88.2040
|
|
||||||
cyberspace.nostr1.com,40.7143,-74.0060
|
|
||||||
relay02.lnfi.network,39.0997,-94.5786
|
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
|
|
||||||
relay.davidebtc.me,50.1155,8.6842
|
|
||||||
wot.dtonon.com,37.7621,-122.3971
|
|
||||||
relay.goodmorningbitcoin.com,37.7621,-122.3971
|
|
||||||
articles.layer3.news,37.3394,-121.8950
|
|
||||||
bostr.syobon.net,37.7621,-122.3971
|
|
||||||
nostr.agentcampfire.com,52.3740,4.8897
|
|
||||||
nostr.thebiglake.org,32.7244,-96.6755
|
|
||||||
schnorr.me,37.7621,-122.3971
|
|
||||||
relay.wolfcoil.com,35.6090,139.7302
|
|
||||||
nostr.camalolo.com,24.1469,120.6839
|
|
||||||
nostr.tac.lol,47.4740,-122.2610
|
|
||||||
dev-relay.lnfi.network,39.0997,-94.5786
|
|
||||||
relay.bitcoinveneto.org,64.1355,-21.8954
|
|
||||||
nostr.red5d.dev,37.7621,-122.3971
|
|
||||||
relay-testnet.k8s.layer3.news,37.3394,-121.8950
|
|
||||||
promenade.fiatjaf.com,50.1155,8.6842
|
|
||||||
nostrelay.memory-art.xyz,37.7621,-122.3971
|
|
||||||
inbox.azzamo.net,52.2284,21.0522
|
|
||||||
social.proxymana.net,60.1695,24.9354
|
|
||||||
relay.netstr.io,53.3331,-6.2489
|
|
||||||
premium.primal.net,37.7621,-122.3971
|
|
||||||
nostr.lojong.info,37.7621,-122.3971
|
|
||||||
nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971
|
|
||||||
relay.stream.labs.h3.se,59.3294,18.0687
|
|
||||||
tollbooth.stens.dev,51.4566,7.0123
|
|
||||||
relay.chakany.systems,37.7621,-122.3971
|
|
||||||
relay.mwaters.net,50.9519,1.8563
|
|
||||||
nostr-relay.shirogaku.xyz,37.7621,-122.3971
|
|
||||||
kitchen.zap.cooking,37.7621,-122.3971
|
|
||||||
relay.arx-ccn.com,50.4779,12.3713
|
|
||||||
relay.fr13nd5.com,50.1155,8.6842
|
|
||||||
nostr.tegila.com.br,39.0437,-77.4875
|
|
||||||
relay.jeffg.fyi,43.7064,-79.3986
|
|
||||||
relay.bullishbounty.com,37.7621,-122.3971
|
|
||||||
nostr.spicyz.io,37.7621,-122.3971
|
|
||||||
relay04.lnfi.network,39.0997,-94.5786
|
|
||||||
vidono.apps.slidestr.net,48.8534,2.3488
|
|
||||||
relay03.lnfi.network,39.0997,-94.5786
|
relay03.lnfi.network,39.0997,-94.5786
|
||||||
communities.nos.social,40.8344,-74.1377
|
nproxy.kristapsk.lv,60.1699,24.9384
|
||||||
relay.evanverma.com,40.8344,-74.1377
|
relay.toastr.net,40.8054,-74.0241
|
||||||
nostrelay.circum.space,51.4566,7.0123
|
relay.davidebtc.me,51.5072,-0.127586
|
||||||
wot.brightbolt.net,47.6928,-116.7850
|
wot.nostr.net,43.6532,-79.3832
|
||||||
relayrs.notoshi.win,37.7621,-122.3971
|
relay.mattybs.lol,43.6532,-79.3832
|
||||||
fenrir-s.notoshi.win,37.7621,-122.3971
|
relay.zone667.com,60.1699,24.9384
|
||||||
relay.nsnip.io,60.1695,24.9354
|
nostr.kungfu-g.rip,33.7946,-84.4488
|
||||||
x.kojira.io,37.7621,-122.3971
|
relay.mccormick.cx,52.3563,4.95714
|
||||||
relay.hasenpfeffr.com,39.0437,-77.4875
|
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||||
relay01.lnfi.network,39.0997,-94.5786
|
nostr.data.haus,50.4754,12.3683
|
||||||
nostr.rtvslawenia.com,49.4542,11.0775
|
vitor.nostr1.com,40.7128,-74.006
|
||||||
relay.g1sms.fr,43.9298,2.1480
|
purpura.cloud,43.6532,-79.3832
|
||||||
nostr.kalf.org,52.3740,4.8897
|
relay2.angor.io,48.1046,11.6002
|
||||||
nostr.rblb.it,37.7621,-122.3971
|
nos.lol,50.4754,12.3683
|
||||||
nostr.4rs.nl,49.4453,11.0222
|
nostr.rohoss.com,50.1109,8.68213
|
||||||
relay.vrtmrz.net,37.7621,-122.3971
|
strfry.bonsai.com,37.8715,-122.273
|
||||||
nostr.hoppe-relay.it.com,45.5946,-121.1787
|
relay.fountain.fm,39.0997,-94.5786
|
||||||
relay-rpi.edufeed.org,49.4453,11.0222
|
relay.npubhaus.com,43.6532,-79.3832
|
||||||
relay.copylaradio.com,50.7990,-1.0913
|
relay.nostr.wirednet.jp,34.706,135.493
|
||||||
relay.ru.ac.th,13.7540,100.5014
|
soloco.nl,43.6532,-79.3832
|
||||||
relay.bitcoinartclock.com,50.4779,12.3713
|
shu01.shugur.net,21.4902,39.2246
|
||||||
wot.downisontheup.ca,47.6062,-122.3321
|
nostr.davidebtc.me,51.5072,-0.127586
|
||||||
nostr.coincards.com,43.7064,-79.3986
|
pyramid.fiatjaf.com,50.1109,8.68213
|
||||||
relay.etch.social,41.2619,-95.8608
|
relay.ditto.pub,43.6532,-79.3832
|
||||||
relay.mess.ch,47.1345,9.0964
|
relay.nostr.vet,52.6467,4.7395
|
||||||
relay.holzeis.me,37.7621,-122.3971
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
relay-admin.thaliyal.com,40.8220,-74.4488
|
ribo.eu.nostria.app,52.3676,4.90414
|
||||||
nostr.thaliyal.com,40.8220,-74.4488
|
relay.ngengine.org,43.6532,-79.3832
|
||||||
strfry.felixzieger.de,50.1025,8.6299
|
relay.bitcoinveneto.org,64.1466,-21.9426
|
||||||
nostr.smut.cloud,37.7621,-122.3971
|
no.str.cr,9.92857,-84.0528
|
||||||
r.bitcoinhold.net,37.7621,-122.3971
|
relay.primal.net,43.6532,-79.3832
|
||||||
nostr.blankfors.se,60.1695,24.9354
|
ynostr.yael.at,60.1699,24.9384
|
||||||
portal-relay.pareto.space,49.4453,11.0222
|
nostr.camalolo.com,24.1469,120.684
|
||||||
relay.getsafebox.app,43.7064,-79.3986
|
purplerelay.com,50.1109,8.68213
|
||||||
relay.anzenkodo.workers.dev,37.7621,-122.3971
|
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||||
relay.nostrhub.tech,49.4453,11.0222
|
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||||
nostr.prl.plus,52.3740,4.8897
|
wheat.happytavern.co,43.6532,-79.3832
|
||||||
nostr-2.21crypto.ch,46.5160,6.6328
|
nostr.lostr.space,43.6532,-79.3832
|
||||||
nostr.zenon.network,40.7143,-74.0060
|
relay.tagayasu.xyz,43.6715,-79.38
|
||||||
nostr-relay.amethyst.name,35.7721,-78.6386
|
relay.varke.eu,52.6921,6.19372
|
||||||
relayone.geektank.ai,17.1210,-61.8433
|
free.relayted.de,50.1109,8.68213
|
||||||
fanfares.nostr1.com,40.7143,-74.0060
|
nostr.thebiglake.org,32.71,-96.6745
|
||||||
wot.geektank.ai,17.1210,-61.8433
|
nostr.lojong.info,43.6532,-79.3832
|
||||||
relay-dev.satlantis.io,40.8344,-74.1377
|
nostr.now,36.55,139.733
|
||||||
relay.siamdev.cc,13.9178,100.4240
|
relay.jmoose.rocks,60.1699,24.9384
|
||||||
relay.nosto.re,51.3700,6.1681
|
relay.holzeis.me,43.6532,-79.3832
|
||||||
wot.soundhsa.com,39.0997,-94.5786
|
nostr.roundrockbitcoiners.com,40.8054,-74.0241
|
||||||
nostr.n7ekb.net,47.5707,-122.2221
|
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||||
relayone.soundhsa.com,39.0997,-94.5786
|
relay2.ngengine.org,43.6532,-79.3832
|
||||||
relay.puresignal.news,37.7621,-122.3971
|
nostr.snowbla.de,60.1699,24.9384
|
||||||
relay.nostx.io,37.7621,-122.3971
|
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
|
||||||
nostr.now,35.6090,139.7302
|
shu04.shugur.net,25.2604,55.2989
|
||||||
relay.artiostr.ch,37.7621,-122.3971
|
relay.fr13nd5.com,52.5233,13.3426
|
||||||
relay.oldenburg.cool,50.1155,8.6842
|
nostr.vulpem.com,49.4543,11.0746
|
||||||
theoutpost.life,64.1355,-21.8954
|
temp.iris.to,43.6532,-79.3832
|
||||||
khatru.nostrver.se,51.3700,6.1681
|
x.kojira.io,43.6532,-79.3832
|
||||||
relay.wavefunc.live,37.7915,-122.4018
|
adre.su,59.9311,30.3609
|
||||||
nostr-relay.zimage.com,34.0522,-118.2437
|
nostr-dev.wellorder.net,45.5201,-122.99
|
||||||
relay.javi.space,43.4628,11.8807
|
nostr.mom,50.4754,12.3683
|
||||||
bostr.shop,42.8865,-78.8784
|
relay.nostr.place,32.7767,-96.797
|
||||||
relay.letsfo.com,52.2298,21.0118
|
|
||||||
alien.macneilmediagroup.com,37.7621,-122.3971
|
|
||||||
rn1.sotiras.org,37.7621,-122.3971
|
|
||||||
gnostr.com,42.6975,23.3241
|
|
||||||
relay.conduit.market,37.7621,-122.3971
|
|
||||||
relay.hivetalk.org,37.3924,-121.9623
|
|
||||||
nostr.l484.com,30.2960,-97.6396
|
|
||||||
relay.chorus.community,50.1155,8.6842
|
|
||||||
nostr-relay.moe.gift,37.7621,-122.3971
|
|
||||||
relay.nostrcal.com,37.7621,-122.3971
|
|
||||||
temp.iris.to,37.7621,-122.3971
|
|
||||||
librerelay.aaroniumii.com,37.7621,-122.3971
|
|
||||||
nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971
|
|
||||||
relay.barine.co,37.7621,-122.3971
|
|
||||||
nostr.rohoss.com,48.1374,11.5755
|
|
||||||
wot.nostr.place,30.2672,-97.7431
|
wot.nostr.place,30.2672,-97.7431
|
||||||
relay.utxo.farm,34.7331,135.8183
|
nostr.carroarmato0.be,50.9928,3.26317
|
||||||
relay.bankless.at,37.7621,-122.3971
|
nostrelay.circum.space,51.2217,6.77616
|
||||||
relay.toastr.net,40.8043,-74.0121
|
relay.chorus.community,50.1109,8.68213
|
||||||
nostr.excentered.com,52.5244,13.4105
|
relay.nostr.net,50.4754,12.3683
|
||||||
relay.mccormick.cx,52.3740,4.8897
|
relay.nostr-check.me,43.6532,-79.3832
|
||||||
relay.cypherflow.ai,48.8534,2.3488
|
relay.nostrhub.fr,48.1046,11.6002
|
||||||
relay.laantungir.net,45.3134,-73.8725
|
relay.nostraddress.com,43.6532,-79.3832
|
||||||
nostr.veladan.dev,37.7621,-122.3971
|
nostr.rblb.it,43.4633,11.8796
|
||||||
nostr.tadryanom.me,37.7621,-122.3971
|
nostr.red5d.dev,43.6532,-79.3832
|
||||||
nostr-relay.online,37.7621,-122.3971
|
santo.iguanatech.net,40.8302,-74.1299
|
||||||
nostr.night7.space,50.4779,12.3713
|
relay02.lnfi.network,39.0997,-94.5786
|
||||||
dev-nostr.bityacht.io,25.0531,121.5264
|
relay.21e6.cz,50.1682,14.0546
|
||||||
|
a.nos.lol,50.4754,12.3683
|
||||||
|
shu02.shugur.net,21.4902,39.2246
|
||||||
|
schnorr.me,43.6532,-79.3832
|
||||||
|
nostr.n7ekb.net,47.4941,-122.294
|
||||||
|
wot.shaving.kiwi,43.6532,-79.3832
|
||||||
|
dev-nostr.bityacht.io,25.0797,121.234
|
||||||
|
relay.credenso.cafe,43.1149,-80.7228
|
||||||
|
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||||
|
relay.mess.ch,47.3591,8.55292
|
||||||
|
inbox.azzamo.net,52.2633,21.0283
|
||||||
|
prl.plus,55.7623,37.6381
|
||||||
|
yabu.me,35.6092,139.73
|
||||||
|
relayrs.notoshi.win,43.6532,-79.3832
|
||||||
|
premium.primal.net,43.6532,-79.3832
|
||||||
|
nostr.coincrowd.fund,39.0438,-77.4874
|
||||||
|
nostr.2b9t.xyz,34.0549,-118.243
|
||||||
|
nostr.thaliyal.com,40.8218,-74.45
|
||||||
|
relay.exit.pub,50.4754,12.3683
|
||||||
|
nostr.jfischer.org,49.0291,8.35696
|
||||||
|
relay.origin.land,35.6673,139.751
|
||||||
|
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||||
|
relay.nostriot.com,41.5695,-83.9786
|
||||||
|
relay.btcforplebs.com,43.6532,-79.3832
|
||||||
|
relay.chakany.systems,43.6532,-79.3832
|
||||||
|
nostr.openhoofd.nl,51.9229,4.40833
|
||||||
|
nostrcheck.me,43.6532,-79.3832
|
||||||
|
nostr.plantroon.com,50.1013,8.62643
|
||||||
|
satsage.xyz,37.3986,-121.964
|
||||||
|
nostr.faultables.net,43.6532,-79.3832
|
||||||
|
nostr.calitabby.net,39.9268,-75.0246
|
||||||
|
relay.freeplace.nl,52.3676,4.90414
|
||||||
|
relay.nostrhub.tech,49.4543,11.0746
|
||||||
|
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832
|
||||||
|
relay.arx-ccn.com,50.4754,12.3683
|
||||||
|
cyberspace.nostr1.com,40.7128,-74.006
|
||||||
|
nostr.smut.cloud,43.6532,-79.3832
|
||||||
|
nostr-02.czas.top,53.471,9.88208
|
||||||
|
relay.tapestry.ninja,40.8054,-74.0241
|
||||||
|
relay.mostro.network,40.8302,-74.1299
|
||||||
|
wot.brightbolt.net,47.6735,-116.781
|
||||||
|
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||||
|
nostr.rikmeijer.nl,50.4754,12.3683
|
||||||
|
relay.artx.market,43.652,-79.3633
|
||||||
|
strfry.felixzieger.de,50.1013,8.62643
|
||||||
|
relay.seq1.net,43.6532,-79.3832
|
||||||
|
relay.cosmicbolt.net,37.3986,-121.964
|
||||||
|
relay.electriclifestyle.com,26.2897,-80.1293
|
||||||
|
r.bitcoinhold.net,43.6532,-79.3832
|
||||||
|
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||||
|
relay.stream.labs.h3.se,59.4016,17.9455
|
||||||
|
relay.unknown.cloud,43.6532,-79.3832
|
||||||
|
nostr-02.yakihonne.com,1.32123,103.695
|
||||||
|
relay.coinos.io,43.6532,-79.3832
|
||||||
|
relay5.bitransfer.org,43.6532,-79.3832
|
||||||
|
relay-dev.satlantis.io,40.8302,-74.1299
|
||||||
|
nostream.breadslice.com,43.6532,-79.3832
|
||||||
|
relay.fundstr.me,42.3601,-71.0589
|
||||||
|
nostr.oxtr.dev,50.4754,12.3683
|
||||||
|
nos.xmark.cc,50.6924,3.20113
|
||||||
|
nostr.mikoshi.de,50.1109,8.68213
|
||||||
|
relay.magiccity.live,25.8128,-80.2377
|
||||||
|
nostr-verified.wellorder.net,45.5201,-122.99
|
||||||
|
nostr.makibisskey.work,43.6532,-79.3832
|
||||||
|
wot.nostr.party,36.1627,-86.7816
|
||||||
|
relay.copylaradio.com,51.223,6.78245
|
||||||
|
nostr.sathoarder.com,48.5734,7.75211
|
||||||
|
relay.jeffg.fyi,43.6532,-79.3832
|
||||||
|
relay.wellorder.net,45.5201,-122.99
|
||||||
|
nostr.ovia.to,43.6532,-79.3832
|
||||||
|
black.nostrcity.club,41.8781,-87.6298
|
||||||
|
relay.nostrcheck.me,43.6532,-79.3832
|
||||||
|
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||||
|
nostr-03.dorafactory.org,1.35208,103.82
|
||||||
|
nostr-relay.zimage.com,34.282,-118.439
|
||||||
|
relay.sigit.io,50.4754,12.3683
|
||||||
|
relay.laantungir.net,-19.4692,-42.5315
|
||||||
|
relay-admin.thaliyal.com,40.8218,-74.45
|
||||||
|
relay.mwaters.net,50.9871,2.12554
|
||||||
|
relay.lumina.rocks,49.0291,8.35695
|
||||||
|
nostr.satstralia.com,64.1476,-21.9392
|
||||||
|
nostr.sagaciousd.com,49.2827,-123.121
|
||||||
|
relay.bullishbounty.com,43.6532,-79.3832
|
||||||
|
wot.dergigi.com,64.1476,-21.9392
|
||||||
|
relay.vrtmrz.net,43.6532,-79.3832
|
||||||
|
nostr.0x7e.xyz,47.4988,8.72369
|
||||||
|
articles.layer3.news,37.3387,-121.885
|
||||||
|
relay.digitalezukunft.cyou,45.5019,-73.5674
|
||||||
|
nostr.notribe.net,40.8302,-74.1299
|
||||||
|
nostr.21crypto.ch,47.4988,8.72369
|
||||||
|
relay.lifpay.me,1.35208,103.82
|
||||||
|
relay.g1sms.fr,43.9432,2.07537
|
||||||
|
relay.snort.social,43.6532,-79.3832
|
||||||
|
relay.getsafebox.app,43.6532,-79.3832
|
||||||
|
relay.moinsen.com,50.4754,12.3683
|
||||||
|
nostr.blankfors.se,60.1699,24.9384
|
||||||
|
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||||
|
orangesync.tech,50.1109,8.68213
|
||||||
|
relay.ru.ac.th,13.7584,100.622
|
||||||
|
orangepiller.org,60.1699,24.9384
|
||||||
|
relay.nostr.band,60.1699,24.9384
|
||||||
|
wot.soundhsa.com,34.0479,-118.256
|
||||||
|
khatru.nostrver.se,51.8933,4.42083
|
||||||
|
nostr.hifish.org,47.4043,8.57398
|
||||||
|
freelay.sovbit.host,64.1476,-21.9392
|
||||||
|
nostr.bilthon.dev,25.8128,-80.2377
|
||||||
|
nostr.night7.space,50.4754,12.3683
|
||||||
|
relay.illuminodes.com,47.6061,-122.333
|
||||||
|
relayone.soundhsa.com,34.0479,-118.256
|
||||||
|
nostr.azzamo.net,52.2633,21.0283
|
||||||
|
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||||
|
nostr.spicyz.io,43.6532,-79.3832
|
||||||
|
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||||
|
nostr.coincards.com,53.5501,-113.469
|
||||||
|
alien.macneilmediagroup.com,43.6532,-79.3832
|
||||||
|
nostrings-relay-dev.fly.dev,41.8781,-87.6298
|
||||||
|
relay.satlantis.io,32.8769,-80.0114
|
||||||
|
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||||
|
relay.agora.social,50.7383,15.0648
|
||||||
|
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||||
|
relay.0xchat.com,1.35208,103.82
|
||||||
|
wot.utxo.one,43.6532,-79.3832
|
||||||
|
relay.bitcoinartclock.com,50.4754,12.3683
|
||||||
|
nostr.zenon.network,43.5009,-70.4428
|
||||||
|
wot.codingarena.top,50.4754,12.3683
|
||||||
|
nostr-02.dorafactory.org,1.35208,103.82
|
||||||
|
relay.nostromo.social,49.4543,11.0746
|
||||||
|
theoutpost.life,64.1476,-21.9392
|
||||||
|
strfry.shock.network,41.8959,-88.2169
|
||||||
|
strfry.openhoofd.nl,51.9229,4.40833
|
||||||
|
itanostr.space,52.2931,4.79099
|
||||||
|
relay.barine.co,43.6532,-79.3832
|
||||||
|
nostr.middling.mydns.jp,35.8099,140.12
|
||||||
|
relay.olas.app,50.4754,12.3683
|
||||||
|
nostr.girino.org,43.6532,-79.3832
|
||||||
|
nostr-relay.online,43.6532,-79.3832
|
||||||
|
relay.evanverma.com,40.8302,-74.1299
|
||||||
|
relay.angor.io,48.1046,11.6002
|
||||||
|
relay.nostrdice.com,-33.8688,151.209
|
||||||
|
ribo.us.nostria.app,41.5868,-93.625
|
||||||
|
relay01.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.namek.link,43.6532,-79.3832
|
||||||
|
relay.aloftus.io,34.0881,-118.379
|
||||||
|
ithurtswhenip.ee,51.223,6.78245
|
||||||
|
ribo.af.nostria.app,-26.2041,28.0473
|
||||||
|
shu05.shugur.net,48.8566,2.35222
|
||||||
|
noxir.kpherox.dev,34.8587,135.509
|
||||||
|
relay.degmods.com,50.4754,12.3683
|
||||||
|
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||||
|
nostr.4rs.nl,49.0291,8.35696
|
||||||
|
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||||
|
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||||
|
fanfares.nostr1.com,40.7128,-74.006
|
||||||
|
relay.nosto.re,51.8933,4.42083
|
||||||
|
nostr.liberty.fans,36.9104,-89.5875
|
||||||
|
nostr-2.21crypto.ch,47.4988,8.72369
|
||||||
|
relay-rpi.edufeed.org,49.4543,11.0746
|
||||||
|
offchain.pub,36.1809,-115.241
|
||||||
|
nostr.88mph.life,43.6532,-79.3832
|
||||||
|
nostr.luisschwab.net,43.6532,-79.3832
|
||||||
|
nostr.casa21.space,43.6532,-79.3832
|
||||||
|
nostr.hekster.org,37.3986,-121.964
|
||||||
|
relay.utxo.farm,35.6916,139.768
|
||||||
|
zap.watch,45.5029,-73.5723
|
||||||
|
nostr-pub.wellorder.net,45.5201,-122.99
|
||||||
|
wot.sovbit.host,64.1466,-21.9426
|
||||||
|
relay.endfiat.money,43.6532,-79.3832
|
||||||
|
nostr2.girino.org,43.6532,-79.3832
|
||||||
|
nostr.jerrynya.fun,31.2304,121.474
|
||||||
|
nostr.spacecitynode.com,29.7057,-95.2706
|
||||||
|
relay.siamdev.cc,13.9178,100.424
|
||||||
|
relay.notoshi.win,13.7829,100.546
|
||||||
|
relay.nostx.io,43.6532,-79.3832
|
||||||
|
relay.cypherflow.ai,48.8566,2.35222
|
||||||
|
relay.letsfo.com,51.098,17.0321
|
||||||
|
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||||
|
gnostr.com,40.9017,29.1616
|
||||||
|
nostr.pleb.one,38.6327,-90.1961
|
||||||
|
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||||
|
nostr.tadryanom.me,43.6532,-79.3832
|
||||||
|
relay.orangepill.ovh,49.1689,-0.358841
|
||||||
|
nostr.stakey.net,52.3676,4.90414
|
||||||
|
nostr.rtvslawenia.com,49.4543,11.0746
|
||||||
|
relay.damus.io,43.6532,-79.3832
|
||||||
|
dev-relay.lnfi.network,39.0997,-94.5786
|
||||||
|
slick.mjex.me,39.048,-77.4817
|
||||||
|
wot.sudocarlos.com,51.5072,-0.127586
|
||||||
|
relay04.lnfi.network,39.0997,-94.5786
|
||||||
|
relay.13room.space,43.6532,-79.3832
|
||||||
|
relay.conduit.market,43.6532,-79.3832
|
||||||
|
nostr.chaima.info,51.223,6.78245
|
||||||
|
|||||||
|
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "bitchat Setup Script"
|
|
||||||
echo "==================="
|
|
||||||
|
|
||||||
# Check if XcodeGen is installed
|
|
||||||
if command -v xcodegen &> /dev/null; then
|
|
||||||
echo "✓ XcodeGen found"
|
|
||||||
echo "Generating Xcode project..."
|
|
||||||
xcodegen generate
|
|
||||||
echo "✓ Project generated successfully"
|
|
||||||
echo ""
|
|
||||||
echo "To open the project, run:"
|
|
||||||
echo " open bitchat.xcodeproj"
|
|
||||||
else
|
|
||||||
echo "⚠️ XcodeGen not found"
|
|
||||||
echo ""
|
|
||||||
echo "You have several options:"
|
|
||||||
echo "1. Install XcodeGen:"
|
|
||||||
echo " brew install xcodegen"
|
|
||||||
echo ""
|
|
||||||
echo "2. Open with Swift Package Manager:"
|
|
||||||
echo " open Package.swift"
|
|
||||||
echo ""
|
|
||||||
echo "3. Create a new Xcode project manually and add the source files"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Project Structure:"
|
|
||||||
echo "- bitchat/ Main source files"
|
|
||||||
echo " - BitchatApp.swift App entry point"
|
|
||||||
echo " - Views/ SwiftUI views"
|
|
||||||
echo " - ViewModels/ View models"
|
|
||||||
echo " - Services/ Bluetooth and encryption"
|
|
||||||
echo " - Protocols/ Protocol definitions"
|
|
||||||
echo ""
|
|
||||||
echo "Remember to:"
|
|
||||||
echo "1. Enable Bluetooth in device settings"
|
|
||||||
echo "2. Run on physical devices (Bluetooth doesn't work in simulator)"
|
|
||||||
echo "3. Test with multiple devices for mesh functionality"
|
|
||||||
Reference in New Issue
Block a user