mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:05:19 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b69b2aea | ||
|
|
a8855dd2ec |
@@ -1,20 +0,0 @@
|
|||||||
# 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,6 +75,3 @@ TestResult.xcresult/
|
|||||||
*.xcresult/
|
*.xcresult/
|
||||||
build.log
|
build.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Local configs
|
|
||||||
Local.xcconfig
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#include "Release.xcconfig"
|
|
||||||
|
|
||||||
// Optional include of local configs
|
|
||||||
#include? "Local.xcconfig"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// 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)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,6 +14,7 @@ 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"
|
||||||
@@ -21,6 +22,8 @@ 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
|
||||||
|
|
||||||
@@ -41,10 +44,15 @@ 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
|
||||||
@@ -67,7 +75,9 @@ 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..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
||||||
|
@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
|
||||||
@@ -96,9 +106,11 @@ 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 bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
@git checkout -- project.yml 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,7 +15,6 @@ 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: [
|
||||||
@@ -23,7 +22,6 @@ 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")
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -94,25 +94,45 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Option 1: Using Xcode
|
### Option 1: Using XcodeGen (Recommended)
|
||||||
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
To run on a device there're a few steps to prepare the code:
|
### Option 2: Using Swift Package Manager
|
||||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
|
||||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
|
||||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
|
||||||
- Entitlements need to be updated manually (TODO: Automate):
|
|
||||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
|
||||||
|
|
||||||
### Option 2: Using `just`
|
1. Open the project in Xcode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install just
|
cd bitchat
|
||||||
|
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.
|
||||||
|
|||||||
+893
-263
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"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.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
@@ -11,9 +11,6 @@ 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
|
||||||
@@ -57,8 +54,8 @@ struct BitchatApp: App {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#endif
|
#endif
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Spin up Tor early; all internet will gate on Tor 100%
|
||||||
NetworkActivationService.shared.start()
|
TorManager.shared.startIfNeeded()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -70,7 +67,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
|
||||||
// Always send Tor to dormant on background for a clean restart later.
|
// Optionally nudge Tor to dormant to save power
|
||||||
TorManager.shared.setAppForeground(false)
|
TorManager.shared.setAppForeground(false)
|
||||||
TorManager.shared.goDormantOnBackground()
|
TorManager.shared.goDormantOnBackground()
|
||||||
// Stop geohash sampling while backgrounded
|
// Stop geohash sampling while backgrounded
|
||||||
@@ -87,14 +84,11 @@ struct BitchatApp: App {
|
|||||||
// On initial cold launch, Tor was just started in onAppear.
|
// On initial cold launch, Tor was just started in onAppear.
|
||||||
// Skip the deterministic restart the first time we become active.
|
// Skip the deterministic restart the first time we become active.
|
||||||
if didHandleInitialActive && didEnterBackground {
|
if didHandleInitialActive && didEnterBackground {
|
||||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
didHandleInitialActive = true
|
didHandleInitialActive = true
|
||||||
}
|
}
|
||||||
didEnterBackground = false
|
didEnterBackground = false
|
||||||
if TorManager.shared.isAutoStartAllowed() {
|
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -104,7 +98,6 @@ struct BitchatApp: App {
|
|||||||
NostrRelayManager.shared.resetAllConnections()
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break
|
break
|
||||||
@@ -137,7 +130,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: BitchatApp.groupID) else {
|
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@
|
|||||||
/// - Advanced conflict resolution
|
/// - Advanced conflict resolution
|
||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,356 +0,0 @@
|
|||||||
//
|
|
||||||
// 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 senderPeer: Peer?
|
|
||||||
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, mentions, deliveryStatus
|
|
||||||
case senderPeer = "senderPeerID" // backwards compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String? = nil,
|
|
||||||
sender: String,
|
|
||||||
content: String,
|
|
||||||
timestamp: Date,
|
|
||||||
isRelay: Bool,
|
|
||||||
originalSender: String? = nil,
|
|
||||||
isPrivate: Bool = false,
|
|
||||||
recipientNickname: String? = nil,
|
|
||||||
senderPeer: Peer? = 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.senderPeer = senderPeer
|
|
||||||
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.senderPeer == rhs.senderPeer &&
|
|
||||||
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 senderPeer != nil { flags |= 0x10 }
|
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
|
||||||
|
|
||||||
data.append(flags)
|
|
||||||
|
|
||||||
// Timestamp (in milliseconds)
|
|
||||||
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
|
||||||
// Encode as 8 bytes, big-endian
|
|
||||||
for i in (0..<8).reversed() {
|
|
||||||
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID
|
|
||||||
if let idData = id.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(idData.count, 255)))
|
|
||||||
data.append(idData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sender
|
|
||||||
if let senderData = sender.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(senderData.count, 255)))
|
|
||||||
data.append(senderData.prefix(255))
|
|
||||||
} else {
|
|
||||||
data.append(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content
|
|
||||||
if let contentData = content.data(using: .utf8) {
|
|
||||||
let length = UInt16(min(contentData.count, 65535))
|
|
||||||
// Encode length as 2 bytes, big-endian
|
|
||||||
data.append(UInt8((length >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(length & 0xFF))
|
|
||||||
data.append(contentData.prefix(Int(length)))
|
|
||||||
} else {
|
|
||||||
data.append(contentsOf: [0, 0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(origData.count, 255)))
|
|
||||||
data.append(origData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
|
||||||
data.append(UInt8(min(recipData.count, 255)))
|
|
||||||
data.append(recipData.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let peerData = senderPeer?.data {
|
|
||||||
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 senderPeer: Peer?
|
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
senderPeer = Peer(data: dataCopy[offset..<offset+length])
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mentions array
|
|
||||||
var mentions: [String]?
|
|
||||||
if hasMentions && offset < dataCopy.count {
|
|
||||||
let mentionCount = Int(dataCopy[offset]); offset += 1
|
|
||||||
if mentionCount > 0 {
|
|
||||||
mentions = []
|
|
||||||
for _ in 0..<mentionCount {
|
|
||||||
if offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
|
||||||
mentions?.append(mention)
|
|
||||||
}
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.init(
|
|
||||||
id: id,
|
|
||||||
sender: sender,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: isRelay,
|
|
||||||
originalSender: originalSender,
|
|
||||||
isPrivate: isPrivate,
|
|
||||||
recipientNickname: recipientNickname,
|
|
||||||
senderPeer: senderPeer,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
//
|
|
||||||
// Peer.swift
|
|
||||||
// BitLogger
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Peer: Equatable, Hashable {
|
|
||||||
let id: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Peer {
|
|
||||||
var data: Data? {
|
|
||||||
id.data(using: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNostr: Bool {
|
|
||||||
id.hasPrefix("nostr")
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNostrColon: Bool {
|
|
||||||
id.hasPrefix("nostr:")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ExpressibleByStringLiteral
|
|
||||||
|
|
||||||
extension Peer: ExpressibleByStringLiteral {
|
|
||||||
init(stringLiteral value: String) {
|
|
||||||
self.init(str: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ExpressibleByStringInterpolation
|
|
||||||
|
|
||||||
extension Peer: ExpressibleByStringInterpolation {
|
|
||||||
init(extendedGraphemeClusterLiteral value: String) {
|
|
||||||
self.init(str: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Codable
|
|
||||||
|
|
||||||
extension Peer: Codable {
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
id = try decoder.singleValueContainer().decode(String.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: any Encoder) throws {
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Inits
|
|
||||||
|
|
||||||
extension Peer {
|
|
||||||
init(str: String) {
|
|
||||||
id = str.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(str: String.SubSequence) {
|
|
||||||
self.init(str: String(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(data: Data) {
|
|
||||||
guard let str = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.init(str: str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||||
|
|||||||
@@ -77,7 +77,6 @@
|
|||||||
/// - 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,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -210,15 +209,6 @@ 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,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -309,15 +308,6 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
return sessions.filter { $0.value.isEstablished() }
|
return sessions.filter { $0.value.isEstablished() }
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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,31 +150,13 @@ struct NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
static func clearAllAssociations() {
|
static func clearAllAssociations() {
|
||||||
let query: [String: Any] = [
|
// Delete current Nostr identity
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||||
kSecAttrService as String: keychainService,
|
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
// Note: We can't efficiently delete all noise-nostr associations
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
// The important part is deleting the current identity so a new one is generated
|
||||||
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,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
@@ -35,12 +34,10 @@ 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 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)
|
||||||
@@ -67,8 +64,6 @@ 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
|
||||||
@@ -82,26 +77,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
// Initialize with default relays
|
||||||
allowDefaultRelays = hasMutual
|
|
||||||
if hasMutual {
|
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||||
}
|
|
||||||
// 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
|
|
||||||
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
|
||||||
guard networkService.activationAllowed else { return }
|
|
||||||
if shouldUseTor {
|
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
Task.detached {
|
Task.detached {
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
@@ -116,12 +99,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
|
||||||
for relay in self.relays {
|
|
||||||
connectToRelay(relay.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
@@ -139,11 +116,7 @@ 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]) {
|
||||||
// Global network policy gate
|
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
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 }
|
||||||
@@ -152,21 +125,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var existing = Set(relays.map { $0.url })
|
let existing = Set(relays.map { $0.url })
|
||||||
for url in targets where !existing.contains(url) {
|
for url in Set(relayUrls) {
|
||||||
|
if !existing.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
|
||||||
}
|
}
|
||||||
for url in targets where connections[url] == nil {
|
if 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) {
|
||||||
// Global network policy gate
|
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
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 }
|
||||||
@@ -175,9 +147,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
let targetRelays = 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
|
||||||
@@ -242,8 +212,6 @@ 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 {
|
||||||
@@ -252,7 +220,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if 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 }
|
||||||
@@ -280,23 +248,19 @@ 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 candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let urls = 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 candidateUrls {
|
for url in urls {
|
||||||
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 {
|
||||||
if urls.isEmpty {
|
|
||||||
onEOSE()
|
|
||||||
} else {
|
|
||||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
@@ -311,7 +275,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
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
|
||||||
ensureConnections(to: urls)
|
ensureConnections(to: urls)
|
||||||
@@ -326,54 +289,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateDefaultRelayPolicy(hasMutual: Bool) {
|
|
||||||
guard hasMutual != allowDefaultRelays else { return }
|
|
||||||
allowDefaultRelays = hasMutual
|
|
||||||
if hasMutual {
|
|
||||||
var existing = Set(relays.map { $0.url })
|
|
||||||
for url in Self.defaultRelays where !existing.contains(url) {
|
|
||||||
relays.append(Relay(url: url))
|
|
||||||
existing.insert(url)
|
|
||||||
}
|
|
||||||
if networkService.activationAllowed {
|
|
||||||
ensureConnections(to: Self.defaultRelays)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for url in Self.defaultRelays {
|
|
||||||
if let connection = connections[url] {
|
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
|
||||||
}
|
|
||||||
connections.removeValue(forKey: url)
|
|
||||||
subscriptions.removeValue(forKey: url)
|
|
||||||
}
|
|
||||||
messageQueueLock.lock()
|
|
||||||
for index in (0..<messageQueue.count).reversed() {
|
|
||||||
var item = messageQueue[index]
|
|
||||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
|
||||||
if item.pendingRelays.isEmpty {
|
|
||||||
messageQueue.remove(at: index)
|
|
||||||
} else {
|
|
||||||
messageQueue[index] = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messageQueueLock.unlock()
|
|
||||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
|
||||||
updateConnectionStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
|
||||||
var seen = Set<String>()
|
|
||||||
var result: [String] = []
|
|
||||||
for url in urls {
|
|
||||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
|
||||||
if seen.insert(url).inserted {
|
|
||||||
result.append(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unsubscribe from a subscription
|
/// Unsubscribe from a subscription
|
||||||
func unsubscribe(id: String) {
|
func unsubscribe(id: String) {
|
||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
@@ -401,15 +316,13 @@ 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 shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +337,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 shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if 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()
|
||||||
@@ -609,13 +522,6 @@ 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,3 +328,236 @@ 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,6 +59,61 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -70,7 +125,6 @@ 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)
|
||||||
@@ -84,7 +138,6 @@ 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"
|
||||||
@@ -128,6 +181,187 @@ 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
|
||||||
@@ -157,6 +391,74 @@ 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 {
|
||||||
@@ -193,3 +495,37 @@ 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,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
@@ -89,9 +88,8 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
var myPeerID: String = ""
|
var myPeerID: String = ""
|
||||||
var myNickname: String = "anon"
|
var myNickname: String = "anon"
|
||||||
private var noiseService: NoiseEncryptionService
|
private let 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
|
||||||
@@ -141,9 +139,6 @@ final class BLEService: NSObject {
|
|||||||
// 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
|
||||||
|
|
||||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||||
@@ -331,45 +326,34 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
|
||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
|
||||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
|
||||||
self?.messageQueue.async { [weak self] in
|
|
||||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
|
||||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
|
||||||
}
|
|
||||||
self?.messageQueue.async { [weak self] in
|
|
||||||
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) {
|
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
self.keychain = keychain
|
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
configureNoiseServiceCallbacks(for: noiseService)
|
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars)
|
||||||
refreshPeerIdentity()
|
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
|
||||||
|
self.myPeerID = String(fingerprint.prefix(16))
|
||||||
|
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
|
||||||
|
|
||||||
// Set queue key for identification
|
// Set queue key for identification
|
||||||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
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))...")
|
||||||
|
// Send any messages that were queued during handshake
|
||||||
|
self?.messageQueue.async { [weak self] in
|
||||||
|
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||||
|
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||||||
|
}
|
||||||
|
// Proactive presence nudge: announce immediately after handshake
|
||||||
|
self?.messageQueue.async { [weak self] in
|
||||||
|
self?.sendAnnounce(forceSend: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set up application state tracking (iOS only)
|
// Set up application state tracking (iOS only)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Check initial state on main thread
|
// Check initial state on main thread
|
||||||
@@ -417,9 +401,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
|
|
||||||
// Initialize gossip sync manager
|
|
||||||
restartGossipManager()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String) {
|
func setNickname(_ nickname: String) {
|
||||||
@@ -748,46 +729,6 @@ final class BLEService: NSObject {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -834,8 +775,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,13 +1099,10 @@ 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 control/presence messages: do NOT subset to maximize immediate coverage
|
// Special-case announces: do NOT subset to maximize reach for presence
|
||||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||||
if directedOnlyPeer == nil
|
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
|
||||||
&& 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)
|
||||||
@@ -1197,12 +1133,6 @@ 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)
|
||||||
@@ -1409,10 +1339,7 @@ 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)
|
||||||
// Special case: allow our own packets recovered via sync (TTL==0) to pass
|
if packet.type != MessageType.fragment.rawValue && messageDeduplicator.isDuplicate(messageID) {
|
||||||
// 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 {
|
||||||
@@ -1456,9 +1383,6 @@ 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)
|
||||||
|
|
||||||
@@ -1659,17 +1583,12 @@ 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)
|
||||||
@@ -1692,32 +1611,16 @@ 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 except when returned via sync (TTL==0).
|
// Ignore self-origin public messages that may be seen again via relay
|
||||||
// This allows our own messages to be surfaced when they come back via
|
if peerID == myPeerID { return }
|
||||||
// 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 the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
if let info = peers[peerID], info.isVerifiedNickname {
|
||||||
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
|
||||||
@@ -1745,22 +1648,6 @@ 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 {
|
||||||
@@ -1893,8 +1780,6 @@ 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 }
|
||||||
@@ -1976,8 +1861,6 @@ 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) {
|
||||||
@@ -2205,8 +2088,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -2368,29 +2249,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -24,8 +23,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 = BitchatApp.bundleID
|
private let service = "chat.bitchat"
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.chat.bitchat"
|
||||||
|
|
||||||
private func isSandboxed() -> Bool {
|
private func isSandboxed() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -282,7 +281,6 @@ 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,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
/// Lightweight background counter for location notes (kind 1) at block-level geohash.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesCounter: ObservableObject {
|
final class LocationNotesCounter: ObservableObject {
|
||||||
static let shared = LocationNotesCounter()
|
static let shared = LocationNotesCounter()
|
||||||
@@ -16,11 +16,10 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
|
|
||||||
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 { return }
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
cancel()
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = norm
|
geohash = norm
|
||||||
|
count = 0
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Routes messages between BLE and Nostr transports
|
/// Routes messages between BLE and Nostr transports
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,7 +83,6 @@
|
|||||||
/// - Background queue for CPU-intensive operations
|
/// - Background queue for CPU-intensive operations
|
||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -527,15 +526,6 @@ 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
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 private message from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
senderPeer: Peer(str: meshService.myPeerID),
|
senderPeerID: meshService.myPeerID,
|
||||||
mentions: nil,
|
mentions: nil,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
@@ -94,7 +93,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
|
|
||||||
/// Handle incoming private message
|
/// Handle incoming private message
|
||||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||||
guard let senderPeerID = message.senderPeer?.id else { return }
|
guard let senderPeerID = message.senderPeerID else { return }
|
||||||
|
|
||||||
// Initialize chat if needed
|
// Initialize chat if needed
|
||||||
if privateChats[senderPeerID] == nil {
|
if privateChats[senderPeerID] == nil {
|
||||||
@@ -161,7 +160,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
for message in messages {
|
for message in messages {
|
||||||
if message.senderPeer?.id == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
sendReadReceipt(for: message)
|
sendReadReceipt(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +213,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !sentReadReceipts.contains(message.id),
|
guard !sentReadReceipts.contains(message.id),
|
||||||
let senderPeerID = message.senderPeer?.id else {
|
let senderPeerID = message.senderPeerID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ static void *tor_thread_main(void *arg) {
|
|||||||
int rc = tor_run_main(cfg); // blocks until tor exits
|
int rc = tor_run_main(cfg); // blocks until tor exits
|
||||||
tor_main_configuration_free(cfg);
|
tor_main_configuration_free(cfg);
|
||||||
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
|
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
|
||||||
// Do not close owning_fd_tor here: Tor may have already closed it and the
|
if (owning_fd_tor != -1) { close(owning_fd_tor); owning_fd_tor = -1; }
|
||||||
// fd number could have been re-used by the time we get here. On iOS 18,
|
|
||||||
// attempting to close a re-used guarded fd can trigger EXC_GUARD. Treat the
|
|
||||||
// tor-side end as owned by Tor and simply forget our reference.
|
|
||||||
owning_fd_tor = -1;
|
|
||||||
return (void*)(intptr_t)rc;
|
return (void*)(intptr_t)rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Darwin
|
import Darwin
|
||||||
@@ -68,27 +67,19 @@ 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()
|
||||||
@@ -481,8 +472,6 @@ 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
|
||||||
@@ -494,39 +483,18 @@ 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() {
|
||||||
// Prefer Tor's DORMANT mode so we can resume on foreground without a full restart.
|
// Stricter model: fully stop Tor when app backgrounds to save power
|
||||||
// If the control port is unreachable, fall back to a hard shutdown.
|
// and avoid half-suspended states. We'll restart cleanly on .active.
|
||||||
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
|
||||||
@@ -539,24 +507,6 @@ 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
|
||||||
@@ -566,28 +516,14 @@ 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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,62 +589,6 @@ 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 }
|
||||||
@@ -755,14 +635,3 @@ 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,6 +3,4 @@ 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,34 +4,43 @@ 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. Allows swapping between proxied and direct
|
/// when Tor is enforced/ready. Falls back to a default session only when
|
||||||
/// sessions so UI can toggle Tor usage at runtime.
|
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
||||||
final class TorURLSession {
|
final class TorURLSession {
|
||||||
static let shared = TorURLSession()
|
static let shared = TorURLSession()
|
||||||
|
|
||||||
// Default (no proxy) session for direct Nostr access when Tor is disabled.
|
// Default (no proxy) session for local development when dev bypass is enabled.
|
||||||
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
|
private var defaultSession: URLSession = {
|
||||||
|
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 {
|
||||||
useTorProxy ? torSession : defaultSession
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
|
// 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() {
|
||||||
defaultSession = TorURLSession.makeDefaultSession()
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
|
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
|
||||||
@@ -54,10 +63,4 @@ 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 = 2.0
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
||||||
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,7 +6,6 @@
|
|||||||
// 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
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
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 [0, M) via (h64 % M).
|
|
||||||
// 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)
|
|
||||||
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
|
||||||
let n = min(ids.count, cap)
|
|
||||||
let selected = Array(ids.prefix(n))
|
|
||||||
// Map to [0, M)
|
|
||||||
let mInit = UInt32(n << p)
|
|
||||||
var mapped = selected.map { id16 -> UInt64 in
|
|
||||||
let h = h64(id16)
|
|
||||||
return UInt64(h % UInt64(max(1, mInit)))
|
|
||||||
}.sorted()
|
|
||||||
var encoded = encode(sorted: mapped, p: p)
|
|
||||||
var trimmedN = n
|
|
||||||
// Trim if over budget
|
|
||||||
while encoded.count > maxBytes && trimmedN > 0 {
|
|
||||||
trimmedN = (trimmedN * 9) / 10 // drop ~10%
|
|
||||||
mapped = Array(mapped.prefix(trimmedN))
|
|
||||||
encoded = encode(sorted: mapped, p: p)
|
|
||||||
}
|
|
||||||
let finalM = UInt32(max(1, trimmedN << p))
|
|
||||||
return Params(p: p, m: finalM, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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) {
|
|
||||||
// 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 {
|
|
||||||
var hasher = SHA256()
|
|
||||||
hasher.update(data: id) // 16-byte PacketId
|
|
||||||
let digest = hasher.finalize()
|
|
||||||
let db = Data(digest)
|
|
||||||
var x: UInt64 = 0
|
|
||||||
let take = min(8, db.count)
|
|
||||||
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
|
||||||
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
|
|
||||||
return GCSFilter.contains(sortedValues: sorted, candidate: v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// OSLog+Categories.swift
|
// OSLog+Categories.swift
|
||||||
// BitLogger
|
// bitchat
|
||||||
//
|
//
|
||||||
// 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
|
||||||
|
|
||||||
public extension OSLog {
|
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")
|
||||||
+44
-48
@@ -1,6 +1,6 @@
|
|||||||
//
|
//
|
||||||
// SecureLogger.swift
|
// SecureLogger.swift
|
||||||
// BitLogger
|
// bitchat
|
||||||
//
|
//
|
||||||
// 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
|
||||||
public final class SecureLogger {
|
final class SecureLogger {
|
||||||
|
|
||||||
// MARK: - Timestamp Formatter
|
// MARK: - Timestamp Formatter
|
||||||
|
|
||||||
@@ -85,50 +85,8 @@ public 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
|
// MARK: - Security Event Types
|
||||||
|
|
||||||
public extension SecureLogger {
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
||||||
@@ -153,6 +111,30 @@ public extension 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)
|
||||||
}
|
}
|
||||||
@@ -168,11 +150,25 @@ public extension 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
|
||||||
|
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: - Convenience Extensions
|
// MARK: - Convenience Extensions
|
||||||
|
|
||||||
public extension SecureLogger {
|
extension SecureLogger {
|
||||||
|
|
||||||
enum KeyOperation: String, CustomStringConvertible {
|
enum KeyOperation: String, CustomStringConvertible {
|
||||||
case load
|
case load
|
||||||
@@ -181,7 +177,7 @@ public extension SecureLogger {
|
|||||||
case delete
|
case delete
|
||||||
case save
|
case save
|
||||||
|
|
||||||
public var description: String { rawValue }
|
var description: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log key management operations
|
/// Log key management operations
|
||||||
@@ -294,7 +290,7 @@ 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(...)
|
||||||
public func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\n",
|
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)
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//
|
|
||||||
// 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, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -86,14 +86,10 @@ struct AppInfoView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { dismiss() }) {
|
Button("close") {
|
||||||
Image(systemName: "xmark")
|
dismiss()
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,16 +459,11 @@ struct ContentView: View {
|
|||||||
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
selectedMessageSender = name
|
selectedMessageSender = name
|
||||||
} else {
|
} else {
|
||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeer?.id == peerID && $0.sender != "system" })?.sender
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
|
|
||||||
selectedMessageSender = nil
|
|
||||||
selectedMessageSenderID = nil
|
|
||||||
} else {
|
|
||||||
showMessageActions = true
|
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 }
|
||||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||||
@@ -1145,31 +1140,7 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Open unread private chat")
|
.accessibilityLabel("Open unread private chat")
|
||||||
}
|
}
|
||||||
// Notes icon (mesh only and when location is authorized), to the left of #mesh
|
// Bookmark toggle for current geohash (not shown for mesh)
|
||||||
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
|
|
||||||
Button(action: {
|
|
||||||
// Kick a one-shot refresh and show the sheet immediately.
|
|
||||||
LocationChannelManager.shared.enableLocationChannels()
|
|
||||||
LocationChannelManager.shared.refreshChannels()
|
|
||||||
// If we already have a block geohash, pass it; otherwise wait in the sheet.
|
|
||||||
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
|
||||||
showLocationNotes = true
|
|
||||||
}) {
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
|
||||||
let currentCount = (notesCounter.count ?? 0)
|
|
||||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Location notes for this place")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bookmark toggle (geochats): to the left of #geohash
|
|
||||||
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")
|
||||||
@@ -1178,7 +1149,6 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location channels button '#'
|
// Location channels button '#'
|
||||||
Button(action: { showLocationChannelsSheet = true }) {
|
Button(action: { showLocationChannelsSheet = true }) {
|
||||||
let badgeText: String = {
|
let badgeText: String = {
|
||||||
@@ -1204,8 +1174,29 @@ struct ContentView: View {
|
|||||||
.accessibilityLabel("location channels")
|
.accessibilityLabel("location channels")
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 4)
|
|
||||||
.padding(.trailing, 2)
|
// Notes icon (mesh only and when location is authorized), to the right of #mesh
|
||||||
|
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
|
||||||
|
Button(action: {
|
||||||
|
// Kick a one-shot refresh and show the sheet immediately.
|
||||||
|
LocationChannelManager.shared.enableLocationChannels()
|
||||||
|
LocationChannelManager.shared.refreshChannels()
|
||||||
|
// If we already have a block geohash, pass it; otherwise wait in the sheet.
|
||||||
|
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||||
|
showLocationNotes = true
|
||||||
|
}) {
|
||||||
|
HStack(alignment: .center, spacing: 4) {
|
||||||
|
let hasNotes = ((notesCounter.count ?? 0) > 0) || (sheetNotesCount > 0)
|
||||||
|
Image(systemName: "long.text.page.and.pencil")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(hasNotes ? Color(hue: 0.60, saturation: 0.85, brightness: 0.82) : Color.gray)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Location notes for this place")
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
// People icon with count
|
// People icon with count
|
||||||
@@ -1217,7 +1208,6 @@ struct ContentView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
.padding(.leading, 2)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
|
||||||
@@ -1287,6 +1277,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
LocationChannelManager.shared.endLiveRefresh()
|
LocationChannelManager.shared.endLiveRefresh()
|
||||||
|
sheetNotesCount = 0
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.availableChannels) { channels in
|
.onChange(of: locationManager.availableChannels) { channels in
|
||||||
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
||||||
@@ -1533,16 +1524,8 @@ extension ContentView {
|
|||||||
if locationManager.permissionState == .authorized {
|
if locationManager.permissionState == .authorized {
|
||||||
LocationChannelManager.shared.refreshChannels()
|
LocationChannelManager.shared.refreshChannels()
|
||||||
}
|
}
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||||
LocationNotesCounter.shared.subscribe(geohash: building)
|
LocationNotesCounter.shared.subscribe(geohash: building)
|
||||||
} else {
|
|
||||||
// Keep existing subscription if we had one to avoid flicker
|
|
||||||
// Only cancel if we have no known geohash
|
|
||||||
if LocationNotesCounter.shared.geohash == nil {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LocationNotesCounter.shared.cancel()
|
LocationNotesCounter.shared.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,17 @@ struct LocationChannelsSheet: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@ObservedObject private var manager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
@ObservedObject private var network = NetworkActivationService.shared
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var customGeohash: String = ""
|
@State private var customGeohash: String = ""
|
||||||
@State private var customError: String? = nil
|
@State private var customError: String? = nil
|
||||||
|
|
||||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("#location channels")
|
Text("#location channels")
|
||||||
.font(.system(size: 18, design: .monospaced))
|
.font(.system(size: 18, design: .monospaced))
|
||||||
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.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
@@ -57,30 +54,19 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(backgroundColor)
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { isPresented = false }) {
|
Button("close") { isPresented = false }
|
||||||
Image(systemName: "xmark")
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .automatic) {
|
ToolbarItem(placement: .automatic) {
|
||||||
Button(action: { isPresented = false }) {
|
Button("close") { isPresented = false }
|
||||||
Image(systemName: "xmark")
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -91,7 +77,6 @@ struct LocationChannelsSheet: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
.background(backgroundColor)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Refresh channels when opening
|
// Refresh channels when opening
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
@@ -220,11 +205,12 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
// Bookmarked geohashes
|
// Bookmarked geohashes
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
if !bookmarks.bookmarks.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("bookmarked")
|
Text("bookmarked")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
VStack(spacing: 6) {
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
let channel = GeohashChannel(level: level, geohash: gh)
|
||||||
@@ -258,16 +244,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
|
||||||
.background(Color.secondary.opacity(0.12))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer action inside the list
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
torToggleSection
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openSystemLocationSettings()
|
openSystemLocationSettings()
|
||||||
}) {
|
}) {
|
||||||
@@ -281,12 +260,9 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(backgroundColor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
@@ -411,36 +387,8 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TOR Toggle & Standardized Colors
|
// MARK: - 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(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text("hides your ip for location channels. recommended: on.")
|
|
||||||
.font(.system(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)
|
||||||
}
|
}
|
||||||
@@ -449,34 +397,6 @@ 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(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Coverage helpers
|
// MARK: - Coverage helpers
|
||||||
extension LocationChannelsSheet {
|
extension LocationChannelsSheet {
|
||||||
private func coverageString(forPrecision len: Int) -> String {
|
private func coverageString(forPrecision len: Int) -> String {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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 == .block })?.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 block = channels.first(where: { $0.level == .block }) {
|
||||||
|
notesGeohash = block.geohash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,6 @@ struct LocationNotesView: View {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
// 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 {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -59,11 +55,8 @@ struct LocationNotesView: View {
|
|||||||
let c = manager.notes.count
|
let c = manager.notes.count
|
||||||
Text("\(c) \(c == 1 ? "note" : "notes") ")
|
Text("\(c) \(c == 1 ? "note" : "notes") ")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
Text("@ ")
|
Text("@ #\(geohash)")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.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 {
|
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
|
||||||
Text(buildingName)
|
Text(buildingName)
|
||||||
@@ -97,18 +90,10 @@ struct LocationNotesView: View {
|
|||||||
ForEach(manager.notes) { note in
|
ForEach(manager.notes) { note in
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
// Show @name without the #abcd suffix; timestamp in brackets
|
Text(note.displayName)
|
||||||
HStack(spacing: 0) {
|
|
||||||
Text("@")
|
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
let parts = splitSuffix(from: note.displayName)
|
Text(timestampText(for: note.createdAt))
|
||||||
Text(parts.0)
|
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
let ts = timestampText(for: note.createdAt)
|
|
||||||
Text(ts.isEmpty ? "" : "[\(ts)]")
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||||
}
|
}
|
||||||
@@ -189,16 +174,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, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ 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
|
||||||
@@ -35,9 +32,8 @@ 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 {
|
|
||||||
self.processShare()
|
processShare()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Processing
|
// MARK: - Processing
|
||||||
@@ -153,7 +149,7 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") 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")
|
||||||
@@ -164,7 +160,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: [], completionHandler: nil)
|
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeer: "REMOTE123",
|
senderPeerID: "REMOTE123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeer: "PACKET123",
|
senderPeerID: "PACKET123",
|
||||||
mentions: nil
|
mentions: 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(packet.payload) {
|
if let msg = BitchatMessage.fromBinaryPayload(packet.payload) {
|
||||||
XCTAssertEqual(msg.content, "Test delivery")
|
XCTAssertEqual(msg.content, "Test delivery")
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeer: "TEST123",
|
senderPeerID: "TEST123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(packet.payload),
|
let message = BitchatMessage.fromBinaryPayload(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(decrypted) {
|
if let message = BitchatMessage.fromBinaryPayload(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(packet.payload),
|
if let message = BitchatMessage.fromBinaryPayload(packet.payload),
|
||||||
message.sender == TestConstants.testNickname1 {
|
message.sender == TestConstants.testNickname1 {
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
@@ -111,7 +111,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
originalSender: message.sender,
|
originalSender: message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeer?.id,
|
senderPeerID: message.senderPeerID,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -437,9 +437,9 @@ 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(packet.payload) {
|
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeer?.id != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
@@ -451,7 +451,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeer?.id,
|
senderPeerID: message.senderPeerID,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(packet.payload),
|
let message = BitchatMessage.fromBinaryPayload(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(decrypted) {
|
let message = BitchatMessage.fromBinaryPayload(decrypted) {
|
||||||
bobDecrypted = message.content == "Secret message"
|
bobDecrypted = message.content == "Secret message"
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
@@ -626,8 +626,8 @@ 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(packet.payload) {
|
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
||||||
guard message.senderPeer?.id != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -638,7 +638,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeer?.id,
|
senderPeerID: message.senderPeerID,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(packet.payload) {
|
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
||||||
var shouldDeliver = false
|
var shouldDeliver = false
|
||||||
seenLock.lock()
|
seenLock.lock()
|
||||||
if !seenMessageIDs.contains(message.id) {
|
if !seenMessageIDs.contains(message.id) {
|
||||||
@@ -331,7 +331,7 @@ final class MockBLEService: NSObject {
|
|||||||
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
||||||
for neighbor in neighbors() {
|
for neighbor in neighbors() {
|
||||||
// Avoid immediate echo loopback to sender if known
|
// Avoid immediate echo loopback to sender if known
|
||||||
if let sender = message.senderPeer?.id, sender == neighbor.peerID { continue }
|
if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
|
||||||
var relay = packet
|
var relay = packet
|
||||||
relay.ttl = nextTTL
|
relay.ttl = nextTTL
|
||||||
neighbor.simulateIncomingPacket(relay)
|
neighbor.simulateIncomingPacket(relay)
|
||||||
|
|||||||
@@ -197,14 +197,14 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let decodedMessage = BitchatMessage(payload) else {
|
guard let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
||||||
XCTFail("Failed to decode message from binary")
|
XCTFail("Failed to decode message from binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(decodedMessage.content, message.content)
|
XCTAssertEqual(decodedMessage.content, message.content)
|
||||||
XCTAssertEqual(decodedMessage.sender, message.sender)
|
XCTAssertEqual(decodedMessage.sender, message.sender)
|
||||||
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeer?.id)
|
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
|
||||||
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
|
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
|
||||||
|
|
||||||
// Timestamp should be close (within 1 second due to conversion)
|
// Timestamp should be close (within 1 second due to conversion)
|
||||||
@@ -219,7 +219,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
guard let payload = message.toBinaryPayload(),
|
||||||
let decodedMessage = BitchatMessage(payload) else {
|
let decodedMessage = BitchatMessage.fromBinaryPayload(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(payload) else {
|
let decodedMessage = BitchatMessage.fromBinaryPayload(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(payload) else {
|
let decodedMessage = BitchatMessage.fromBinaryPayload(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(payload) else {
|
let decodedMessage = BitchatMessage.fromBinaryPayload(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(payload) else {
|
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
|
||||||
XCTFail("Failed to handle empty message")
|
XCTFail("Failed to handle empty message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final class TestHelpers {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: recipientNickname,
|
recipientNickname: recipientNickname,
|
||||||
senderPeer: senderPeerID,
|
senderPeerID: senderPeerID,
|
||||||
mentions: mentions
|
mentions: mentions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
// 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"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
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
|
||||||
+288
-259
@@ -1,264 +1,293 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
relay.laantungir.net,-19.4692,-42.5315
|
relay.damus.io,37.7621,-122.3971
|
||||||
relay.endfiat.money,43.6532,-79.3832
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
relay.zone667.com,60.1699,24.9384
|
nostr.mom,50.4779,12.3713
|
||||||
shu04.shugur.net,25.2604,55.2989
|
nostr.slothy.win,37.7621,-122.3971
|
||||||
relay.bitcoinartclock.com,50.4754,12.3683
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
relay.nostromo.social,49.4543,11.0746
|
nos.lol,50.4779,12.3713
|
||||||
nostr.liberty.fans,36.9104,-89.5875
|
relay.nostr.band,60.1695,24.9354
|
||||||
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832
|
no.str.cr,9.9339,-84.0849
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
nostr.massmux.com,50.1155,8.6842
|
||||||
relay.nostr.wirednet.jp,34.706,135.493
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
nostr.einundzwanzig.space,50.1109,8.68213
|
relay.nostr.com.au,37.7621,-122.3971
|
||||||
relay.21e6.cz,50.1682,14.0546
|
knostr.neutrine.com,48.8534,2.3488
|
||||||
relay04.lnfi.network,39.0997,-94.5786
|
nostr.nodeofsven.com,47.4875,8.2965
|
||||||
relay.chorus.community,50.1109,8.68213
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
relay.nostr.place,32.7767,-96.797
|
nostr-verif.slothy.win,37.7621,-122.3971
|
||||||
relay.vrtmrz.net,43.6532,-79.3832
|
relay.lexingtonbitcoin.org,37.7621,-122.3971
|
||||||
noxir.kpherox.dev,34.8587,135.509
|
nostr-1.nbo.angani.co,-1.2615,36.7903
|
||||||
wot.nostr.net,43.6532,-79.3832
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
relay.cypherflow.ai,48.8566,2.35222
|
nostr.easydns.ca,43.7064,-79.3986
|
||||||
wot.sudocarlos.com,51.5072,-0.127586
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
nostr.jerrynya.fun,31.2304,121.474
|
nostr.data.haus,50.4779,12.3713
|
||||||
nostr2.girino.org,43.6532,-79.3832
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
nostrings-relay-dev.fly.dev,41.8781,-87.6298
|
nostr.mom,50.4779,12.3713
|
||||||
fanfares.nostr1.com,40.7128,-74.006
|
nos.lol,50.4779,12.3713
|
||||||
nostr.red5d.dev,43.6532,-79.3832
|
relay.nostr.band,60.1695,24.9354
|
||||||
nostr.hifish.org,47.4043,8.57398
|
nostr.massmux.com,50.1155,8.6842
|
||||||
nostr.now,36.55,139.733
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
relay.nostr.band,60.1699,24.9384
|
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
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
nostr.bilthon.dev,25.8128,-80.2377
|
nostr.sagaciousd.com,49.2497,-123.1193
|
||||||
khatru.nostrver.se,51.8933,4.42083
|
nostr.fbxl.net,43.7064,-79.3986
|
||||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
ithurtswhenip.ee,50.7990,-1.0913
|
||||||
nostr.makibisskey.work,43.6532,-79.3832
|
relay2.nostrchat.io,49.4542,11.0775
|
||||||
relay.nostraddress.com,43.6532,-79.3832
|
relay1.nostrchat.io,60.1695,24.9354
|
||||||
relay.jmoose.rocks,60.1699,24.9384
|
nostr-01.yakihonne.com,1.3215,103.6957
|
||||||
relay.davidebtc.me,51.5072,-0.127586
|
nostr.sathoarder.com,48.5839,7.7455
|
||||||
a.nos.lol,50.4754,12.3683
|
nostr.overmind.lol,37.7621,-122.3971
|
||||||
nostr.tadryanom.me,43.6532,-79.3832
|
relay.verified-nostr.com,37.7621,-122.3971
|
||||||
relay.nostrdice.com,-33.8688,151.209
|
purplerelay.com,50.1155,8.6842
|
||||||
relay.lumina.rocks,49.0291,8.35695
|
relay.orangepill.ovh,49.0127,1.9694
|
||||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
nostr-relay.psfoundation.info,39.0437,-77.4875
|
||||||
nostr.rtvslawenia.com,49.4543,11.0746
|
soloco.nl,37.7621,-122.3971
|
||||||
relay.mattybs.lol,43.6532,-79.3832
|
relay.froth.zone,60.1695,24.9354
|
||||||
relay-dev.satlantis.io,40.8302,-74.1299
|
nostr.stakey.net,52.5250,5.7181
|
||||||
nostream.breadslice.com,43.6532,-79.3832
|
nostr.2b9t.xyz,34.0522,-118.2437
|
||||||
nostr.vulpem.com,49.4543,11.0746
|
pyramid.fiatjaf.com,50.1155,8.6842
|
||||||
nostr.rohoss.com,50.1109,8.68213
|
a.nos.lol,50.4779,12.3713
|
||||||
articles.layer3.news,37.3387,-121.885
|
relay.magiccity.live,25.8130,-80.2320
|
||||||
nos.lol,50.4754,12.3683
|
nostr.notribe.net,40.8344,-74.1377
|
||||||
relay.artx.market,43.652,-79.3633
|
freelay.sovbit.host,64.1355,-21.8954
|
||||||
wot.sebastix.social,51.8933,4.42083
|
relay.credenso.cafe,43.4254,-80.5112
|
||||||
alien.macneilmediagroup.com,43.6532,-79.3832
|
nostr.huszonegy.world,47.4984,19.0404
|
||||||
relay.unknown.cloud,43.6532,-79.3832
|
multiplexer.huszonegy.world,47.4984,19.0404
|
||||||
nostr.lojong.info,43.6532,-79.3832
|
bucket.coracle.social,37.7621,-122.3971
|
||||||
nostr.zenon.network,43.5009,-70.4428
|
nostr.kungfu-g.rip,33.7865,-84.4454
|
||||||
orangesync.tech,50.1109,8.68213
|
relay.artx.market,43.7064,-79.3986
|
||||||
nostr.davidebtc.me,51.5072,-0.127586
|
relay.notoshi.win,13.3622,100.9835
|
||||||
internationalright-wing.org,-22.5022,-48.7114
|
vitor.nostr1.com,40.7143,-74.0060
|
||||||
nostr.rikmeijer.nl,50.4754,12.3683
|
nostr-02.yakihonne.com,1.3215,103.6957
|
||||||
ynostr.yael.at,60.1699,24.9384
|
nostr-03.dorafactory.org,1.2897,103.8501
|
||||||
ithurtswhenip.ee,51.223,6.78245
|
n.ok0.org,-36.8485,174.7635
|
||||||
relay.wellorder.net,45.5201,-122.99
|
nostr.0x7e.xyz,47.5056,8.7241
|
||||||
nostr.sathoarder.com,48.5734,7.75211
|
relay.nostr.net,50.4779,12.3713
|
||||||
purplerelay.com,50.1109,8.68213
|
strfry.openhoofd.nl,51.5717,3.7042
|
||||||
yabu.me,35.6092,139.73
|
|
||||||
nostr.88mph.life,43.6532,-79.3832
|
|
||||||
nostr.overmind.lol,43.6532,-79.3832
|
|
||||||
rnostr.breadslice.com,43.6532,-79.3832
|
|
||||||
zap.watch,45.5029,-73.5723
|
|
||||||
wot.basspistol.org,49.4521,11.0767
|
|
||||||
shu01.shugur.net,21.4902,39.2246
|
|
||||||
relay.electriclifestyle.com,26.2897,-80.1293
|
|
||||||
relay.mccormick.cx,52.3563,4.95714
|
|
||||||
nostr.middling.mydns.jp,35.8099,140.12
|
|
||||||
nostr.smut.cloud,43.6532,-79.3832
|
|
||||||
satsage.xyz,37.3986,-121.964
|
|
||||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
|
||||||
nostr.0x7e.xyz,47.4988,8.72369
|
|
||||||
shu02.shugur.net,21.4902,39.2246
|
|
||||||
nostrelites.org,41.8781,-87.6298
|
|
||||||
relay-admin.thaliyal.com,40.8218,-74.45
|
|
||||||
wot.soundhsa.com,34.0479,-118.256
|
|
||||||
nostrcheck.me,43.6532,-79.3832
|
|
||||||
relay.nostrhub.tech,49.4543,11.0746
|
|
||||||
relay.stream.labs.h3.se,59.4016,17.9455
|
|
||||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
|
||||||
nostr.n7ekb.net,47.4941,-122.294
|
|
||||||
relay.nosto.re,51.8933,4.42083
|
|
||||||
nostr.girino.org,43.6532,-79.3832
|
|
||||||
relay.siamdev.cc,13.9178,100.424
|
|
||||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
|
||||||
orangepiller.org,60.1699,24.9384
|
|
||||||
nostr.plantroon.com,50.1013,8.62643
|
|
||||||
nostr-verified.wellorder.net,45.5201,-122.99
|
|
||||||
relay.primal.net,43.6532,-79.3832
|
|
||||||
relay.bitcoinveneto.org,64.1466,-21.9426
|
|
||||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
|
||||||
strfry.openhoofd.nl,51.9229,4.40833
|
|
||||||
relay.aloftus.io,34.0881,-118.379
|
|
||||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
|
||||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
|
||||||
ribo.af.nostria.app,-26.2041,28.0473
|
|
||||||
nostr.tac.lol,47.4748,-122.273
|
|
||||||
relay.satlantis.io,32.8769,-80.0114
|
|
||||||
nostr.azzamo.net,52.2633,21.0283
|
|
||||||
strfry.bonsai.com,37.8715,-122.273
|
|
||||||
relay.agora.social,50.7383,15.0648
|
|
||||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
|
||||||
relay.toastr.net,40.8054,-74.0241
|
|
||||||
nostr.thebiglake.org,32.71,-96.6745
|
|
||||||
nostr-relay.nextblockvending.com,47.674,-122.122
|
|
||||||
vitor.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.btcforplebs.com,43.6532,-79.3832
|
|
||||||
relay.g1sms.fr,43.9432,2.07537
|
|
||||||
nostr.jfischer.org,49.0291,8.35696
|
|
||||||
nostr.mikoshi.de,52.52,13.405
|
|
||||||
relay.notoshi.win,13.7829,100.546
|
|
||||||
pyramid.fiatjaf.com,50.1109,8.68213
|
|
||||||
relay.coinos.io,43.6532,-79.3832
|
|
||||||
relay.freeplace.nl,52.3676,4.90414
|
|
||||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
|
||||||
relay.copylaradio.com,51.223,6.78245
|
|
||||||
relay.exit.pub,50.4754,12.3683
|
|
||||||
freelay.sovbit.host,64.1476,-21.9392
|
|
||||||
nostr.satstralia.com,64.1476,-21.9392
|
|
||||||
nostr.l484.com,30.2944,-97.6223
|
|
||||||
nostr.rblb.it,43.4633,11.8796
|
|
||||||
nostr.2b9t.xyz,34.0549,-118.243
|
|
||||||
nostr.dlsouza.lol,50.1109,8.68213
|
|
||||||
strfry.shock.network,41.8959,-88.2169
|
|
||||||
offchain.pub,36.1809,-115.241
|
|
||||||
nostr-01.yakihonne.com,1.32123,103.695
|
|
||||||
nostr.kungfu-g.rip,33.7946,-84.4488
|
|
||||||
relay.letsfo.com,51.098,17.0321
|
|
||||||
relay.lifpay.me,1.35208,103.82
|
|
||||||
relay.damus.io,43.6532,-79.3832
|
|
||||||
relay2.angor.io,48.1046,11.6002
|
|
||||||
relayrs.notoshi.win,43.6532,-79.3832
|
|
||||||
relay2.ngengine.org,43.6532,-79.3832
|
|
||||||
portal-relay.pareto.space,49.4543,11.0746
|
|
||||||
inbox.azzamo.net,52.2633,21.0283
|
|
||||||
nostr-dev.wellorder.net,45.5201,-122.99
|
|
||||||
nostr.stakey.net,52.3676,4.90414
|
|
||||||
relay.13room.space,43.6532,-79.3832
|
|
||||||
relay.fountain.fm,39.0997,-94.5786
|
relay.fountain.fm,39.0997,-94.5786
|
||||||
black.nostrcity.club,41.8781,-87.6298
|
relay.usefusion.ai,38.7135,-78.1594
|
||||||
nostr-2.21crypto.ch,47.4988,8.72369
|
relay.varke.eu,52.6958,6.1944
|
||||||
dev-nostr.bityacht.io,25.0797,121.234
|
nostr.satstralia.com,64.1355,-21.8954
|
||||||
santo.iguanatech.net,40.8302,-74.1299
|
relay.13room.space,37.7621,-122.3971
|
||||||
relay.angor.io,48.1046,11.6002
|
nostr.myshosholoza.co.za,52.3710,4.9042
|
||||||
relay.tagayasu.xyz,43.6715,-79.38
|
nostr.carroarmato0.be,50.8517,3.6089
|
||||||
relay.npubhaus.com,43.6532,-79.3832
|
nostr.dbtc.link,37.7621,-122.3971
|
||||||
relay01.lnfi.network,39.0997,-94.5786
|
orangepiller.org,60.1695,24.9354
|
||||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
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
|
relay02.lnfi.network,39.0997,-94.5786
|
||||||
gnostr.com,40.9017,29.1616
|
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
|
||||||
nostr.sagaciousd.com,49.2827,-123.121
|
relay.davidebtc.me,50.1155,8.6842
|
||||||
nostr.night7.space,50.4754,12.3683
|
wot.dtonon.com,37.7621,-122.3971
|
||||||
schnorr.me,43.6532,-79.3832
|
relay.goodmorningbitcoin.com,37.7621,-122.3971
|
||||||
nostr.blankfors.se,60.1699,24.9384
|
articles.layer3.news,37.3394,-121.8950
|
||||||
relay.mostro.network,40.8302,-74.1299
|
bostr.syobon.net,37.7621,-122.3971
|
||||||
purpura.cloud,43.6532,-79.3832
|
nostr.agentcampfire.com,52.3740,4.8897
|
||||||
ribo.eu.nostria.app,52.3676,4.90414
|
nostr.thebiglake.org,32.7244,-96.6755
|
||||||
vidono.apps.slidestr.net,48.8566,2.35222
|
schnorr.me,37.7621,-122.3971
|
||||||
wheat.happytavern.co,43.6532,-79.3832
|
relay.wolfcoil.com,35.6090,139.7302
|
||||||
nostr.faultables.net,43.6532,-79.3832
|
nostr.camalolo.com,24.1469,120.6839
|
||||||
relay5.bitransfer.org,43.6532,-79.3832
|
nostr.tac.lol,47.4740,-122.2610
|
||||||
relay.nostrhub.fr,48.1046,11.6002
|
|
||||||
nostr.thaliyal.com,40.8218,-74.45
|
|
||||||
relay.holzeis.me,43.6532,-79.3832
|
|
||||||
relay.nostriot.com,41.5695,-83.9786
|
|
||||||
nostr.openhoofd.nl,51.9229,4.40833
|
|
||||||
relay.nostr.vet,52.6467,4.7395
|
|
||||||
nostr.camalolo.com,24.1469,120.684
|
|
||||||
relay.origin.land,35.6673,139.751
|
|
||||||
relay.chakany.systems,43.6532,-79.3832
|
|
||||||
relay.0xchat.com,1.35208,103.82
|
|
||||||
nostr.mom,50.4754,12.3683
|
|
||||||
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
|
|
||||||
prl.plus,55.7623,37.6381
|
|
||||||
relay.moinsen.com,50.4754,12.3683
|
|
||||||
nostr-02.czas.top,53.471,9.88208
|
|
||||||
relay.sigit.io,50.4754,12.3683
|
|
||||||
relay.nostrcheck.me,43.6532,-79.3832
|
|
||||||
relay03.lnfi.network,39.0997,-94.5786
|
|
||||||
relay.sincensura.org,43.6532,-79.3832
|
|
||||||
nostr.coincards.com,53.5501,-113.469
|
|
||||||
nostr-03.dorafactory.org,1.35208,103.82
|
|
||||||
relay.credenso.cafe,43.1149,-80.7228
|
|
||||||
nostr.fbxl.net,48.3809,-89.2477
|
|
||||||
relay.bullishbounty.com,43.6532,-79.3832
|
|
||||||
nos.xmark.cc,50.6924,3.20113
|
|
||||||
x.kojira.io,43.6532,-79.3832
|
|
||||||
wot.sovbit.host,64.1466,-21.9426
|
|
||||||
shu05.shugur.net,48.8566,2.35222
|
|
||||||
nostr.carroarmato0.be,50.9928,3.26317
|
|
||||||
relay.cosmicbolt.net,37.3986,-121.964
|
|
||||||
r.bitcoinhold.net,43.6532,-79.3832
|
|
||||||
nostr.diakod.com,43.6532,-79.3832
|
|
||||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
|
||||||
nostr.coincrowd.fund,39.0438,-77.4874
|
|
||||||
cyberspace.nostr1.com,40.7128,-74.006
|
|
||||||
relay.barine.co,43.6532,-79.3832
|
|
||||||
relay.orangepill.ovh,49.1689,-0.358841
|
|
||||||
no.str.cr,9.92857,-84.0528
|
|
||||||
nostr.casa21.space,43.6532,-79.3832
|
|
||||||
relay.mwaters.net,50.9871,2.12554
|
|
||||||
relay.magiccity.live,25.8128,-80.2377
|
|
||||||
relayone.soundhsa.com,34.0479,-118.256
|
|
||||||
slick.mjex.me,39.048,-77.4817
|
|
||||||
relay.utxo.farm,35.6916,139.768
|
|
||||||
theoutpost.life,64.1476,-21.9392
|
|
||||||
nostr.hekster.org,37.3986,-121.964
|
|
||||||
strfry.felixzieger.de,50.1013,8.62643
|
|
||||||
relay.mess.ch,47.3591,8.55292
|
|
||||||
wot.codingarena.top,50.4754,12.3683
|
|
||||||
nostrelay.circum.space,51.2217,6.77616
|
|
||||||
nostr-relay.online,43.6532,-79.3832
|
|
||||||
temp.iris.to,43.6532,-79.3832
|
|
||||||
wot.dergigi.com,64.1476,-21.9392
|
|
||||||
wot.brightbolt.net,47.6735,-116.781
|
|
||||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
|
||||||
wot.nostr.place,30.2672,-97.7431
|
|
||||||
ribo.us.nostria.app,41.5868,-93.625
|
|
||||||
relay.nostr.net,50.4754,12.3683
|
|
||||||
nostr-02.dorafactory.org,1.35208,103.82
|
|
||||||
relay.tapestry.ninja,40.8054,-74.0241
|
|
||||||
adre.su,59.9311,30.3609
|
|
||||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
|
||||||
nostr-pub.wellorder.net,45.5201,-122.99
|
|
||||||
kitchen.zap.cooking,43.6532,-79.3832
|
|
||||||
nostr.21crypto.ch,47.4988,8.72369
|
|
||||||
nostr-02.yakihonne.com,1.32123,103.695
|
|
||||||
relay.javi.space,43.4633,11.8796
|
|
||||||
nostr.ser1.net,12.9716,77.5946
|
|
||||||
relay-rpi.edufeed.org,49.4543,11.0746
|
|
||||||
premium.primal.net,43.6532,-79.3832
|
|
||||||
relay.degmods.com,50.4754,12.3683
|
|
||||||
relay.arx-ccn.com,50.4754,12.3683
|
|
||||||
nostr.chaima.info,51.223,6.78245
|
|
||||||
relay.illuminodes.com,47.6061,-122.333
|
|
||||||
relay.nostx.io,43.6532,-79.3832
|
|
||||||
relay.puresignal.news,43.6532,-79.3832
|
|
||||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
|
||||||
relay.getsafebox.app,43.6532,-79.3832
|
|
||||||
relay.conduit.market,43.6532,-79.3832
|
|
||||||
relay.jeffg.fyi,43.6532,-79.3832
|
|
||||||
nproxy.kristapsk.lv,60.1699,24.9384
|
|
||||||
relay.olas.app,50.4754,12.3683
|
|
||||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
|
||||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
|
||||||
nostr.pleb.one,38.6327,-90.1961
|
|
||||||
relay.digitalezukunft.cyou,45.5019,-73.5674
|
|
||||||
relay.evanverma.com,40.8302,-74.1299
|
|
||||||
wot.dtonon.com,43.6532,-79.3832
|
|
||||||
relay.seq1.net,43.6532,-79.3832
|
|
||||||
nostr.kalf.org,52.3676,4.90414
|
|
||||||
nostr.snowbla.de,60.1699,24.9384
|
|
||||||
nostr.spicyz.io,43.6532,-79.3832
|
|
||||||
nostr-relay.zimage.com,34.282,-118.439
|
|
||||||
nostr.spacecitynode.com,29.7057,-95.2706
|
|
||||||
dev-relay.lnfi.network,39.0997,-94.5786
|
dev-relay.lnfi.network,39.0997,-94.5786
|
||||||
itanostr.space,52.2931,4.79099
|
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
|
||||||
|
communities.nos.social,40.8344,-74.1377
|
||||||
|
relay.evanverma.com,40.8344,-74.1377
|
||||||
|
nostrelay.circum.space,51.4566,7.0123
|
||||||
|
wot.brightbolt.net,47.6928,-116.7850
|
||||||
|
relayrs.notoshi.win,37.7621,-122.3971
|
||||||
|
fenrir-s.notoshi.win,37.7621,-122.3971
|
||||||
|
relay.nsnip.io,60.1695,24.9354
|
||||||
|
x.kojira.io,37.7621,-122.3971
|
||||||
|
relay.hasenpfeffr.com,39.0437,-77.4875
|
||||||
|
relay01.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.rtvslawenia.com,49.4542,11.0775
|
||||||
|
relay.g1sms.fr,43.9298,2.1480
|
||||||
|
nostr.kalf.org,52.3740,4.8897
|
||||||
|
nostr.rblb.it,37.7621,-122.3971
|
||||||
|
nostr.4rs.nl,49.4453,11.0222
|
||||||
|
relay.vrtmrz.net,37.7621,-122.3971
|
||||||
|
nostr.hoppe-relay.it.com,45.5946,-121.1787
|
||||||
|
relay-rpi.edufeed.org,49.4453,11.0222
|
||||||
|
relay.copylaradio.com,50.7990,-1.0913
|
||||||
|
relay.ru.ac.th,13.7540,100.5014
|
||||||
|
relay.bitcoinartclock.com,50.4779,12.3713
|
||||||
|
wot.downisontheup.ca,47.6062,-122.3321
|
||||||
|
nostr.coincards.com,43.7064,-79.3986
|
||||||
|
relay.etch.social,41.2619,-95.8608
|
||||||
|
relay.mess.ch,47.1345,9.0964
|
||||||
|
relay.holzeis.me,37.7621,-122.3971
|
||||||
|
relay-admin.thaliyal.com,40.8220,-74.4488
|
||||||
|
nostr.thaliyal.com,40.8220,-74.4488
|
||||||
|
strfry.felixzieger.de,50.1025,8.6299
|
||||||
|
nostr.smut.cloud,37.7621,-122.3971
|
||||||
|
r.bitcoinhold.net,37.7621,-122.3971
|
||||||
|
nostr.blankfors.se,60.1695,24.9354
|
||||||
|
portal-relay.pareto.space,49.4453,11.0222
|
||||||
|
relay.getsafebox.app,43.7064,-79.3986
|
||||||
|
relay.anzenkodo.workers.dev,37.7621,-122.3971
|
||||||
|
relay.nostrhub.tech,49.4453,11.0222
|
||||||
|
nostr.prl.plus,52.3740,4.8897
|
||||||
|
nostr-2.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.zenon.network,40.7143,-74.0060
|
||||||
|
nostr-relay.amethyst.name,35.7721,-78.6386
|
||||||
|
relayone.geektank.ai,17.1210,-61.8433
|
||||||
|
fanfares.nostr1.com,40.7143,-74.0060
|
||||||
|
wot.geektank.ai,17.1210,-61.8433
|
||||||
|
relay-dev.satlantis.io,40.8344,-74.1377
|
||||||
|
relay.siamdev.cc,13.9178,100.4240
|
||||||
|
relay.nosto.re,51.3700,6.1681
|
||||||
|
wot.soundhsa.com,39.0997,-94.5786
|
||||||
|
nostr.n7ekb.net,47.5707,-122.2221
|
||||||
|
relayone.soundhsa.com,39.0997,-94.5786
|
||||||
|
relay.puresignal.news,37.7621,-122.3971
|
||||||
|
relay.nostx.io,37.7621,-122.3971
|
||||||
|
nostr.now,35.6090,139.7302
|
||||||
|
relay.artiostr.ch,37.7621,-122.3971
|
||||||
|
relay.oldenburg.cool,50.1155,8.6842
|
||||||
|
theoutpost.life,64.1355,-21.8954
|
||||||
|
khatru.nostrver.se,51.3700,6.1681
|
||||||
|
relay.wavefunc.live,37.7915,-122.4018
|
||||||
|
nostr-relay.zimage.com,34.0522,-118.2437
|
||||||
|
relay.javi.space,43.4628,11.8807
|
||||||
|
bostr.shop,42.8865,-78.8784
|
||||||
|
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
|
||||||
|
relay.utxo.farm,34.7331,135.8183
|
||||||
|
relay.bankless.at,37.7621,-122.3971
|
||||||
|
relay.toastr.net,40.8043,-74.0121
|
||||||
|
nostr.excentered.com,52.5244,13.4105
|
||||||
|
relay.mccormick.cx,52.3740,4.8897
|
||||||
|
relay.cypherflow.ai,48.8534,2.3488
|
||||||
|
relay.laantungir.net,45.3134,-73.8725
|
||||||
|
nostr.veladan.dev,37.7621,-122.3971
|
||||||
|
nostr.tadryanom.me,37.7621,-122.3971
|
||||||
|
nostr-relay.online,37.7621,-122.3971
|
||||||
|
nostr.night7.space,50.4779,12.3713
|
||||||
|
dev-nostr.bityacht.io,25.0531,121.5264
|
||||||
|
|||||||
|
@@ -0,0 +1,40 @@
|
|||||||
|
#!/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