mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e142dcda8 | ||
|
|
ef4bdb3856 | ||
|
|
bbe1793506 | ||
|
|
af7a664685 |
@@ -5,25 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Swift Tests (${{ matrix.name }})
|
||||
name: Run Swift Tests
|
||||
runs-on: macos-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
matrix:
|
||||
include:
|
||||
- name: app
|
||||
path: .
|
||||
- name: BitLogger
|
||||
path: localPackages/BitLogger
|
||||
- name: BitFoundation
|
||||
path: localPackages/BitFoundation
|
||||
- name: Noise
|
||||
path: localPackages/Noise
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -31,14 +20,8 @@ jobs:
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
- name: Build the package
|
||||
run: swift build
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||
run: swift test --parallel
|
||||
|
||||
@@ -8,7 +8,6 @@ plans/
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
|
||||
@@ -9,4 +9,3 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||
APP_GROUP_ID = group.chat.bitchat
|
||||
|
||||
+3
-10
@@ -17,8 +17,6 @@ let package = Package(
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/Noise"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
@@ -27,16 +25,13 @@ let package = Package(
|
||||
name: "bitchat",
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Noise", package: "Noise"),
|
||||
.product(name: "Tor", package: "Arti")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"Assets.xcassets",
|
||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
@@ -48,17 +43,15 @@ let package = Package(
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation")
|
||||
],
|
||||
dependencies: ["bitchat"],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization")
|
||||
.process("Localization"),
|
||||
.process("Noise")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+4
-46
@@ -10,10 +10,6 @@
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
|
||||
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
@@ -158,20 +154,16 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
@@ -231,8 +223,6 @@
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -313,8 +303,6 @@
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||
A63163B52F80CB2500B8B128 /* Noise */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -356,8 +344,6 @@
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -562,7 +548,6 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -573,7 +558,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -623,7 +608,6 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -634,7 +618,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -670,7 +654,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -762,7 +746,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -923,14 +907,6 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Noise;
|
||||
};
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitFoundation;
|
||||
};
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
@@ -958,24 +934,6 @@
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
productName = P256K;
|
||||
};
|
||||
A63163B52F80CB2500B8B128 /* Noise */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Noise;
|
||||
};
|
||||
A63163B72F80CB2D00B8B128 /* Noise */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
|
||||
productName = Noise;
|
||||
};
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitFoundation;
|
||||
};
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
|
||||
productName = BitFoundation;
|
||||
};
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
|
||||
@@ -9,18 +9,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
return Int(duration.rounded())
|
||||
}
|
||||
|
||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||
var remainingSeconds: Int {
|
||||
let remaining = max(0, duration - currentTime)
|
||||
return min(roundedDuration, Int(ceil(remaining)))
|
||||
}
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
@@ -2,7 +2,8 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
actor VoiceRecorder {
|
||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
@@ -11,16 +12,21 @@ actor VoiceRecorder {
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var stopWorkItem: DispatchWorkItem?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
nonisolated
|
||||
@discardableResult
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
@@ -41,88 +47,106 @@ actor VoiceRecorder {
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
try queue.sync {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
stopWorkItem?.cancel()
|
||||
stopWorkItem = nil
|
||||
return outputURL
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||
completion(self?.currentURL)
|
||||
return
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
recorder.stop()
|
||||
self.cleanupSession()
|
||||
let url = self.currentURL
|
||||
self.recorder = nil
|
||||
self.currentURL = url
|
||||
completion(url)
|
||||
}
|
||||
self.stopWorkItem = item
|
||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopWorkItem?.cancel()
|
||||
self.stopWorkItem = nil
|
||||
if let recorder = self.recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
self.cleanupSession()
|
||||
if let url = self.currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
self.recorder = nil
|
||||
self.currentURL = nil
|
||||
}
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
func currentAveragePower() -> Float {
|
||||
queue.sync {
|
||||
recorder?.updateMeters()
|
||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Three-Layer Identity Model
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -332,15 +331,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
// Update nickname index
|
||||
if let previousClaimedNickname,
|
||||
previousClaimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||
// Remove old nickname from index if changed
|
||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,16 +532,4 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cache.verifiedFingerprints
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
||||
queue.sync { ephemeralSessions[peerID] }
|
||||
}
|
||||
|
||||
func debugLastInteraction(for fingerprint: String) -> Date? {
|
||||
queue.sync { cache.lastInteractions[fingerprint] }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -2,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -39,12 +37,12 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// BitchatMessage+Media.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
private struct Cache {
|
||||
let filesDir: URL?
|
||||
|
||||
static let shared = Cache()
|
||||
private init() {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAttachment(for nickname: String) -> Media? {
|
||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
||||
|
||||
func url(for category: MimeType.Category) -> URL? {
|
||||
guard content.hasPrefix(category.messagePrefix),
|
||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check outgoing first for sent messages, incoming for received
|
||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
||||
|
||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||
// Files are checked during playback/display, so missing files fail gracefully
|
||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
||||
return directory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
if let url = url(for: .audio) {
|
||||
return .voice(url)
|
||||
}
|
||||
if let url = url(for: .image) {
|
||||
return .image(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+21
-27
@@ -6,39 +6,33 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import class Foundation.DateFormatter
|
||||
|
||||
import struct Foundation.AttributedString
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.Date
|
||||
import struct Foundation.TimeInterval
|
||||
import struct Foundation.UUID
|
||||
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
|
||||
public final class BitchatMessage: Codable {
|
||||
public let id: String
|
||||
public let sender: String
|
||||
public let content: String
|
||||
public let timestamp: Date
|
||||
public let isRelay: Bool
|
||||
public let originalSender: String?
|
||||
public let isPrivate: Bool
|
||||
public let recipientNickname: String?
|
||||
public let senderPeerID: PeerID?
|
||||
public let mentions: [String]? // Array of mentioned nicknames
|
||||
public var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
final class BitchatMessage: Codable {
|
||||
let id: String
|
||||
let sender: String
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
let isRelay: Bool
|
||||
let originalSender: String?
|
||||
let isPrivate: Bool
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: PeerID?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
// Cached formatted text (not included in Codable)
|
||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||
|
||||
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||
}
|
||||
|
||||
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||
}
|
||||
|
||||
@@ -48,7 +42,7 @@ public final class BitchatMessage: Codable {
|
||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||
}
|
||||
|
||||
public init(
|
||||
init(
|
||||
id: String? = nil,
|
||||
sender: String,
|
||||
content: String,
|
||||
@@ -78,7 +72,7 @@ public final class BitchatMessage: Codable {
|
||||
// MARK: - Equatable Conformance
|
||||
|
||||
extension BitchatMessage: Equatable {
|
||||
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||
return lhs.id == rhs.id &&
|
||||
lhs.sender == rhs.sender &&
|
||||
lhs.content == rhs.content &&
|
||||
@@ -336,15 +330,15 @@ extension BitchatMessage {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
public var formattedTimestamp: String {
|
||||
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)
|
||||
public func cleanedAndDeduped() -> [Element] {
|
||||
let arr = filter { $0.content.trimmed.isEmpty == false }
|
||||
func cleanedAndDeduped() -> [Element] {
|
||||
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
guard arr.count > 1 else {
|
||||
return arr
|
||||
}
|
||||
+16
-17
@@ -6,26 +6,25 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.Date
|
||||
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
|
||||
public struct BitchatPacket: Codable {
|
||||
struct BitchatPacket: Codable {
|
||||
let version: UInt8
|
||||
public let type: UInt8
|
||||
public let senderID: Data
|
||||
public let recipientID: Data?
|
||||
public let timestamp: UInt64
|
||||
public let payload: Data
|
||||
public var signature: Data?
|
||||
public var ttl: UInt8
|
||||
public var route: [Data]?
|
||||
public var isRSR: Bool
|
||||
let type: UInt8
|
||||
let senderID: Data
|
||||
let recipientID: Data?
|
||||
let timestamp: UInt64
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
var route: [Data]?
|
||||
var isRSR: Bool
|
||||
|
||||
public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
|
||||
self.version = version
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
@@ -66,18 +65,18 @@ public struct BitchatPacket: Codable {
|
||||
BinaryProtocol.encode(self)
|
||||
}
|
||||
|
||||
public func toBinaryData(padding: Bool = true) -> Data? {
|
||||
func toBinaryData(padding: Bool = true) -> Data? {
|
||||
BinaryProtocol.encode(self, padding: padding)
|
||||
}
|
||||
|
||||
// Backward-compatible helper (defaults to padded encoding)
|
||||
public func toBinaryData() -> Data? {
|
||||
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
|
||||
public func toBinaryDataForSigning() -> Data? {
|
||||
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(
|
||||
@@ -95,7 +94,7 @@ public struct BitchatPacket: Codable {
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
public static func from(_ data: Data) -> BitchatPacket? {
|
||||
static func from(_ data: Data) -> BitchatPacket? {
|
||||
BinaryProtocol.decode(data)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Equatable {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import Foundation
|
||||
|
||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||
+45
-52
@@ -1,27 +1,15 @@
|
||||
//
|
||||
// PeerID.swift
|
||||
// BitFoundation
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.CharacterSet
|
||||
import Foundation
|
||||
|
||||
public struct PeerID: Equatable, Hashable, Sendable {
|
||||
enum Constants {
|
||||
/// 16
|
||||
static let nostrConvKeyPrefixLength = 16
|
||||
/// 8
|
||||
static let nostrShortKeyDisplayLength = 8
|
||||
/// 64
|
||||
fileprivate static let maxIDLength = 64
|
||||
/// 16
|
||||
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
public enum Prefix: String, CaseIterable, Sendable {
|
||||
struct PeerID: Equatable, Hashable {
|
||||
enum Prefix: String, CaseIterable {
|
||||
/// When no prefix is provided
|
||||
case empty = ""
|
||||
/// `"mesh:"`
|
||||
@@ -35,15 +23,15 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
||||
/// `"nostr:"` (+ 8 characters hex)
|
||||
case geoChat = "nostr:"
|
||||
}
|
||||
|
||||
public let prefix: Prefix
|
||||
|
||||
|
||||
let prefix: Prefix
|
||||
|
||||
/// Returns the actual value without any prefix
|
||||
public let bare: String
|
||||
|
||||
let bare: String
|
||||
|
||||
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||
public var id: String { prefix.rawValue + bare }
|
||||
|
||||
var id: String { prefix.rawValue + bare }
|
||||
|
||||
// Private so the callers have to go through a convenience init
|
||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||
self.prefix = prefix
|
||||
@@ -53,17 +41,17 @@ public struct PeerID: Equatable, Hashable, Sendable {
|
||||
|
||||
// MARK: - Convenience Inits
|
||||
|
||||
public extension PeerID {
|
||||
extension PeerID {
|
||||
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||
init(nostr_ pubKey: String) {
|
||||
self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength))
|
||||
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
}
|
||||
|
||||
|
||||
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||
init(nostr pubKey: String) {
|
||||
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength))
|
||||
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
}
|
||||
|
||||
|
||||
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||
init(str: any StringProtocol) {
|
||||
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||
@@ -72,23 +60,23 @@ public extension PeerID {
|
||||
self.init(prefix: .empty, bare: str)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Convenience init to handle `Optional<String>`
|
||||
init?(str: (any StringProtocol)?) {
|
||||
guard let str else { return nil }
|
||||
self.init(str: str)
|
||||
}
|
||||
|
||||
|
||||
/// Convenience init to create PeerID by converting Data to String
|
||||
init?(data: Data) {
|
||||
self.init(str: String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init(hexData: Data) {
|
||||
self.init(str: hexData.hexEncodedString())
|
||||
}
|
||||
|
||||
|
||||
/// Convenience init to "hide" hex-encoding implementation detail
|
||||
init?(hexData: Data?) {
|
||||
guard let hexData else { return nil }
|
||||
@@ -98,12 +86,12 @@ public extension PeerID {
|
||||
|
||||
// MARK: - Noise Public Key Helpers
|
||||
|
||||
public extension PeerID {
|
||||
extension PeerID {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
init(publicKey: Data) {
|
||||
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||
}
|
||||
|
||||
|
||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||
func toShort() -> PeerID {
|
||||
if let noiseKey {
|
||||
@@ -116,11 +104,11 @@ public extension PeerID {
|
||||
// MARK: - Codable
|
||||
|
||||
extension PeerID: Codable {
|
||||
public init(from decoder: any Decoder) throws {
|
||||
init(from decoder: any Decoder) throws {
|
||||
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(id)
|
||||
}
|
||||
@@ -128,27 +116,27 @@ extension PeerID: Codable {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
public extension PeerID {
|
||||
extension PeerID {
|
||||
var isEmpty: Bool {
|
||||
id.isEmpty
|
||||
}
|
||||
|
||||
|
||||
/// Returns true if `id` starts with "`nostr:`"
|
||||
var isGeoChat: Bool {
|
||||
prefix == .geoChat
|
||||
}
|
||||
|
||||
|
||||
/// Returns true if `id` starts with "`nostr_`"
|
||||
var isGeoDM: Bool {
|
||||
prefix == .geoDM
|
||||
}
|
||||
|
||||
|
||||
func toPercentEncoded() -> String {
|
||||
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
}
|
||||
}
|
||||
|
||||
public extension PeerID {
|
||||
extension PeerID {
|
||||
var routingData: Data? {
|
||||
if let direct = Data(hexString: id), direct.count == 8 { return direct }
|
||||
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
|
||||
@@ -164,45 +152,50 @@ public extension PeerID {
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
public extension PeerID {
|
||||
extension PeerID {
|
||||
private enum Constants {
|
||||
static let maxIDLength = 64
|
||||
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
var isValid: Bool {
|
||||
if prefix != .empty {
|
||||
return PeerID(str: bare).isValid
|
||||
}
|
||||
|
||||
|
||||
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||
if isShort || isNoiseKeyHex {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// If length equals short or full but isn't valid hex, reject
|
||||
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !id.isEmpty &&
|
||||
id.count < Constants.maxIDLength &&
|
||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
|
||||
/// Returns true if the `bare` id is all hex
|
||||
var isHex: Bool {
|
||||
bare.allSatisfy { $0.isHexDigit }
|
||||
}
|
||||
|
||||
|
||||
/// Short routing IDs (exact 16-hex)
|
||||
var isShort: Bool {
|
||||
bare.count == Constants.hexIDLength && isHex
|
||||
}
|
||||
|
||||
|
||||
/// Full Noise key hex (exact 64-hex)
|
||||
var isNoiseKeyHex: Bool {
|
||||
noiseKey != nil
|
||||
}
|
||||
|
||||
|
||||
/// Full Noise key (exact 64-hex) as Data
|
||||
var noiseKey: Data? {
|
||||
guard bare.count == Constants.maxIDLength else { return nil }
|
||||
@@ -213,7 +206,7 @@ public extension PeerID {
|
||||
// MARK: - Comparable
|
||||
|
||||
extension PeerID: Comparable {
|
||||
public static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||
lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
@@ -222,7 +215,7 @@ extension PeerID: Comparable {
|
||||
|
||||
extension PeerID: CustomStringConvertible {
|
||||
/// So it returns the actual `id` like before even inside another String
|
||||
public var description: String {
|
||||
var description: String {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
|
||||
+2
-28
@@ -78,7 +78,6 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -385,16 +384,6 @@ final class NoiseCipherState {
|
||||
replayWindow[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setNonceForTesting(_ nonce: UInt64) {
|
||||
self.nonce = nonce
|
||||
}
|
||||
|
||||
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
||||
try extractNonceFromCiphertextPayload(combinedPayload)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Symmetric State
|
||||
@@ -596,9 +585,8 @@ final class NoiseHandshakeState {
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
_ = symmetricState.getHandshakeHash()
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -873,20 +861,6 @@ final class NoiseHandshakeState {
|
||||
func getHandshakeHash() -> Data {
|
||||
return symmetricState.getHandshakeHash()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
||||
try performDHOperation(pattern)
|
||||
}
|
||||
|
||||
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
||||
self.currentPattern = currentPattern
|
||||
}
|
||||
|
||||
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
||||
self.remoteEphemeralPublic = key
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Pattern Extensions
|
||||
@@ -924,7 +898,7 @@ extension NoisePattern {
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
public enum NoiseError: Error {
|
||||
enum NoiseError: Error {
|
||||
case uninitializedCipher
|
||||
case invalidCiphertext
|
||||
case handshakeComplete
|
||||
+5
-8
@@ -7,10 +7,9 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
public final class NoiseRateLimiter {
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
@@ -19,10 +18,8 @@ public final class NoiseRateLimiter {
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
public init() {}
|
||||
|
||||
public func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
@@ -51,7 +48,7 @@ public final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
public func allowMessage(from peerID: PeerID) -> Bool {
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
@@ -87,7 +84,7 @@ public final class NoiseRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
public func resetAll() {
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
+5
-5
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum NoiseSecurityConstants {
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
@@ -28,10 +28,10 @@ public enum NoiseSecurityConstants {
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
public static let maxHandshakesPerMinute = 10
|
||||
public static let maxMessagesPerSecond = 100
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
public static let maxGlobalHandshakesPerMinute = 30
|
||||
public static let maxGlobalMessagesPerSecond = 500
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum NoiseSecurityError: Error {
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
+3
-3
@@ -8,15 +8,15 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct NoiseSecurityValidator {
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
public static func validateMessageSize(_ data: Data) -> Bool {
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
public static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -9,9 +9,8 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import BitFoundation
|
||||
|
||||
public class NoiseSession {
|
||||
class NoiseSession {
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
@@ -182,7 +181,7 @@ public class NoiseSession {
|
||||
}
|
||||
}
|
||||
|
||||
public func isEstablished() -> Bool {
|
||||
func isEstablished() -> Bool {
|
||||
return sessionQueue.sync {
|
||||
if case .established = state {
|
||||
return true
|
||||
@@ -217,8 +216,8 @@ public class NoiseSession {
|
||||
sentHandshakeMessages.removeAll()
|
||||
|
||||
// Clear handshake hash
|
||||
if handshakeHash != nil {
|
||||
keychain.secureClear(&handshakeHash!)
|
||||
if var hash = handshakeHash {
|
||||
keychain.secureClear(&hash)
|
||||
}
|
||||
handshakeHash = nil
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
public enum NoiseSessionError: Error, Equatable {
|
||||
enum NoiseSessionError: Error, Equatable {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
+25
-37
@@ -9,53 +9,31 @@
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
public final class NoiseSessionManager {
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = { peerID, role in
|
||||
SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain: KeychainManagerProtocol,
|
||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||
) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
public func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
public func removeSession(for peerID: PeerID) {
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
@@ -63,7 +41,7 @@ public final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
public func removeAllSessions() {
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
@@ -74,7 +52,7 @@ public final class NoiseSessionManager {
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
public func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
@@ -88,7 +66,12 @@ public final class NoiseSessionManager {
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
@@ -103,7 +86,7 @@ public final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
public func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
@@ -134,7 +117,12 @@ public final class NoiseSessionManager {
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
@@ -173,7 +161,7 @@ public final class NoiseSessionManager {
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
public func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
@@ -181,7 +169,7 @@ public final class NoiseSessionManager {
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
public func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
@@ -191,13 +179,13 @@ public final class NoiseSessionManager {
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||
|
||||
@@ -213,7 +201,7 @@ public final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
public func initiateRekey(for peerID: PeerID) throws {
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
+1
-5
@@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private var sessionStartTime = Date()
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
@@ -77,9 +77,5 @@ final class SecureNoiseSession: NoiseSession {
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
|
||||
func setSessionStartTimeForTesting(_ date: Date) {
|
||||
sessionStartTime = date
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -8,154 +8,39 @@ import AppKit
|
||||
#endif
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
struct GeoRelayDirectoryDependencies {
|
||||
var userDefaults: UserDefaults
|
||||
var notificationCenter: NotificationCenter
|
||||
var now: () -> Date
|
||||
var remoteURL: URL
|
||||
var fetchInterval: TimeInterval
|
||||
var refreshCheckInterval: TimeInterval
|
||||
var retryInitialSeconds: TimeInterval
|
||||
var retryMaxSeconds: TimeInterval
|
||||
var awaitTorReady: @Sendable () async -> Bool
|
||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
||||
var readData: (URL) -> Data?
|
||||
var writeData: (Data, URL) throws -> Void
|
||||
var cacheURL: () -> URL?
|
||||
var bundledCSVURLs: () -> [URL]
|
||||
var currentDirectoryPath: () -> String?
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
var autoStart: Bool
|
||||
}
|
||||
|
||||
private extension GeoRelayDirectoryDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
#if os(iOS)
|
||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||
#elseif os(macOS)
|
||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
readData: { try? Data(contentsOf: $0) },
|
||||
writeData: { data, url in
|
||||
try data.write(to: url, options: .atomic)
|
||||
},
|
||||
cacheURL: {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
bundledCSVURLs: {
|
||||
[
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
},
|
||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||
retrySleep: { delay in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectory {
|
||||
private final class CleanupState {
|
||||
let notificationCenter: NotificationCenter
|
||||
var observers: [NSObjectProtocol] = []
|
||||
var refreshTimer: Timer?
|
||||
var retryTask: Task<Void, Never>?
|
||||
|
||||
init(notificationCenter: NotificationCenter) {
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { notificationCenter.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry: Hashable, Sendable {
|
||||
struct Entry: Hashable {
|
||||
let host: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let dependencies: GeoRelayDirectoryDependencies
|
||||
private let cleanupState: CleanupState
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {
|
||||
self.dependencies = .live()
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
|
||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||
self.dependencies = dependencies
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
deinit {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
@@ -198,13 +83,13 @@ final class GeoRelayDirectory {
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = dependencies.now()
|
||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
@@ -218,73 +103,51 @@ final class GeoRelayDirectory {
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: dependencies.remoteURL,
|
||||
url: remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
|
||||
Task { [weak self] in
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .success(let parsed, let csv):
|
||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||
case .torNotReady:
|
||||
self.handleFetchFailure(.torNotReady)
|
||||
case .invalidData:
|
||||
self.handleFetchFailure(.invalidData)
|
||||
case .network(let description):
|
||||
self.handleFetchFailure(.network(description))
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
await self.handleFetchFailure(.torNotReady)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
guard ready else { return .torNotReady }
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
let parsed = Self.parseCSV(text)
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
await self.handleFetchFailure(.network(error))
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
case network(Error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
@@ -298,8 +161,8 @@ final class GeoRelayDirectory {
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let errorDescription):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||
case .network(let error):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
@@ -308,34 +171,32 @@ final class GeoRelayDirectory {
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = dependencies.retryInitialSeconds
|
||||
let maxDelay = dependencies.retryMaxSeconds
|
||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
cleanupState.retryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.dependencies.retrySleep(delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
retryTask = Task { [weak self] in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await MainActor.run {
|
||||
self.prefetchIfNeeded(force: true)
|
||||
self?.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
cleanupState.retryTask?.cancel()
|
||||
cleanupState.retryTask = nil
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
guard let url = cacheURL() else { return }
|
||||
do {
|
||||
try dependencies.writeData(data, url)
|
||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||
}
|
||||
@@ -344,18 +205,22 @@ final class GeoRelayDirectory {
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
if let cache = cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = dependencies.readData(url),
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
@@ -363,8 +228,8 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
@@ -377,9 +242,10 @@ final class GeoRelayDirectory {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||
guard parts.count >= 3 else { continue }
|
||||
var host = parts[0]
|
||||
host = host.replacingOccurrences(of: "https://", with: "")
|
||||
@@ -393,9 +259,25 @@ final class GeoRelayDirectory {
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = dependencies.notificationCenter
|
||||
let center = NotificationCenter.default
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
@@ -407,26 +289,38 @@ final class GeoRelayDirectory {
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(torReady)
|
||||
observers.append(torReady)
|
||||
|
||||
if let activeNotificationName = dependencies.activeNotificationName {
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: activeNotificationName,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
#if os(iOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
cleanupState.observers.append(didBecomeActive)
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#elseif os(macOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
cleanupState.refreshTimer?.invalidate()
|
||||
let interval = dependencies.refreshCheckInterval
|
||||
refreshTimer?.invalidate()
|
||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
@@ -435,13 +329,9 @@ final class GeoRelayDirectory {
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.refreshTimer = timer
|
||||
refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
var debugRetryAttempt: Int { retryAttempt }
|
||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||
var debugObserverCount: Int { cleanupState.observers.count }
|
||||
}
|
||||
|
||||
// MARK: - Distance
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - BitChat-over-Nostr Adapter
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ struct NostrProtocol {
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if teleported {
|
||||
@@ -152,7 +152,7 @@ struct NostrProtocol {
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
@@ -528,26 +528,6 @@ struct NostrEvent: Codable {
|
||||
signed.sig = signatureHex
|
||||
return signed
|
||||
}
|
||||
|
||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
||||
func isValidSignature() -> Bool {
|
||||
guard let sig = sig,
|
||||
let sigData = Data(hexString: sig),
|
||||
let pubData = Data(hexString: pubkey),
|
||||
sigData.count == 64,
|
||||
pubData.count == 32,
|
||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
||||
let (expectedId, eventHash) = try? calculateEventId(),
|
||||
expectedId == id
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
var messageBytes = [UInt8](eventHash)
|
||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||
return xonly.isValid(signature, for: &messageBytes)
|
||||
}
|
||||
|
||||
private func calculateEventId() throws -> (String, Data) {
|
||||
let serialized = [
|
||||
|
||||
@@ -4,100 +4,6 @@ import Network
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
protocol NostrRelayConnectionProtocol: AnyObject {
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
||||
}
|
||||
|
||||
protocol NostrRelaySessionProtocol {
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
||||
}
|
||||
|
||||
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
|
||||
private let base: URLSessionWebSocketTask
|
||||
|
||||
init(base: URLSessionWebSocketTask) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
func resume() {
|
||||
base.resume()
|
||||
}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
base.cancel(with: closeCode, reason: reason)
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
base.send(message, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
base.receive(completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
base.sendPing(pongReceiveHandler: pongReceiveHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
let base: URLSession
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||
}
|
||||
}
|
||||
|
||||
struct NostrRelayManagerDependencies {
|
||||
var activationAllowed: () -> Bool
|
||||
var userTorEnabled: () -> Bool
|
||||
var hasMutualFavorites: () -> Bool
|
||||
var hasLocationPermission: () -> Bool
|
||||
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
var torEnforced: () -> Bool
|
||||
var torIsReady: () -> Bool
|
||||
var torIsForeground: () -> Bool
|
||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||
var makeSession: () -> NostrRelaySessionProtocol
|
||||
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
var now: () -> Date
|
||||
}
|
||||
|
||||
private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
Self(
|
||||
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||
torEnforced: { TorManager.shared.torEnforced },
|
||||
torIsReady: { TorManager.shared.isReady },
|
||||
torIsForeground: { TorManager.shared.isForeground() },
|
||||
awaitTorReady: { completion in
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
completion(ready)
|
||||
}
|
||||
}
|
||||
},
|
||||
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
now: Date.init
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages WebSocket connections to Nostr relays
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
@@ -135,11 +41,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var hasMutualFavorites: Bool = false
|
||||
private var hasLocationPermission: Bool = false
|
||||
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||
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 messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||
@@ -164,7 +69,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -176,13 +82,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
dependencies.mutualFavoritesPublisher
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
@@ -190,34 +95,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
dependencies.locationPermissionPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
let authorized = (state == .authorized)
|
||||
if authorized == self.hasLocationPermission { return }
|
||||
self.hasLocationPermission = authorized
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
internal init(dependencies: NostrRelayManagerDependencies) {
|
||||
self.dependencies = dependencies
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
dependencies.mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
self.hasMutualFavorites = !favorites.isEmpty
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
dependencies.locationPermissionPublisher
|
||||
LocationChannelManager.shared.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
@@ -232,18 +110,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -270,14 +150,15 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard networkService.activationAllowed else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if ready { self.ensureConnections(to: relayUrls) }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -294,12 +175,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if ready { self.sendEvent(event, to: relayUrls) }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -371,21 +253,24 @@ final class NostrRelayManager: ObservableObject {
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard networkService.activationAllowed else { return }
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
let now = dependencies.now()
|
||||
let now = Date()
|
||||
if messageHandlers[id] != nil {
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -412,7 +297,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
for url in urls {
|
||||
for url in candidateUrls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
@@ -461,7 +346,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if dependencies.activationAllowed() {
|
||||
if networkService.activationAllowed {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
@@ -471,7 +356,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
@@ -516,9 +400,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Send unsubscribe to all relays
|
||||
for (relayUrl, connection) in connections {
|
||||
if subscriptions[relayUrl]?.contains(id) == true {
|
||||
subscriptions[relayUrl]?.remove(id)
|
||||
connection.send(.string(messageString)) { _ in
|
||||
// Local state is cleared before sending so callers can re-subscribe immediately.
|
||||
Task { @MainActor in
|
||||
self.subscriptions[relayUrl]?.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,14 +413,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -550,16 +435,19 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if ready { self.connectToRelay(urlString) }
|
||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if ready { self.connectToRelay(urlString) }
|
||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let session = dependencies.makeSession()
|
||||
let session = TorURLSession.shared.session
|
||||
let task = session.webSocketTask(with: url)
|
||||
|
||||
connections[urlString] = task
|
||||
@@ -607,7 +495,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
}
|
||||
|
||||
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
||||
task.receive { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -617,7 +505,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +569,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) {
|
||||
let req = NostrRequest.event(event)
|
||||
|
||||
do {
|
||||
@@ -713,11 +601,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].isConnected = isConnected
|
||||
relays[index].lastError = error
|
||||
if isConnected {
|
||||
relays[index].lastConnectedAt = dependencies.now()
|
||||
relays[index].lastConnectedAt = Date()
|
||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = dependencies.now()
|
||||
relays[index].lastDisconnectedAt = Date()
|
||||
}
|
||||
}
|
||||
updateConnectionStatus()
|
||||
@@ -733,7 +621,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !dependencies.activationAllowed() {
|
||||
if !networkService.activationAllowed {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
@@ -778,21 +666,19 @@ final class NostrRelayManager: ObservableObject {
|
||||
maxBackoffInterval
|
||||
)
|
||||
|
||||
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
||||
relays[index].nextReconnectTime = nextReconnectTime
|
||||
|
||||
|
||||
// Schedule reconnection with exponential backoff
|
||||
let gen = connectionGeneration
|
||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -806,7 +692,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Reset reconnection attempts
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
|
||||
// Disconnect if connected
|
||||
if let connection = connections[relayUrl] {
|
||||
@@ -827,20 +712,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
nextReconnectTime: relay.nextReconnectTime)
|
||||
}
|
||||
}
|
||||
|
||||
var debugPendingMessageQueueCount: Int {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.count
|
||||
}
|
||||
|
||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||
}
|
||||
|
||||
func debugFlushMessageQueue() {
|
||||
flushMessageQueue(for: nil)
|
||||
}
|
||||
|
||||
/// Reset all relay connections
|
||||
func resetAllConnections() {
|
||||
@@ -893,8 +764,7 @@ private enum ParsedInbound {
|
||||
if array.count >= 3,
|
||||
let subId = array[1] as? String,
|
||||
let eventDict = array[2] as? [String: Any],
|
||||
let event = try? NostrEvent(from: eventDict),
|
||||
event.isValidSignature() {
|
||||
let event = try? NostrEvent(from: eventDict) {
|
||||
self = .event(subId: subId, event: event)
|
||||
return
|
||||
}
|
||||
|
||||
+68
-13
@@ -5,15 +5,70 @@
|
||||
// Binary encoding utilities for efficient protocol messages
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct Foundation.Date
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Hex Encoding/Decoding
|
||||
|
||||
extension Data {
|
||||
func hexEncodedString() -> String {
|
||||
if self.isEmpty {
|
||||
return ""
|
||||
}
|
||||
return self.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func sha256Hex() -> String {
|
||||
let digest = SHA256.hash(data: self)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Initialize Data from a hex string.
|
||||
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
|
||||
/// Whitespace is trimmed. Must have even length after prefix removal.
|
||||
/// - Returns: nil if the string has odd length or contains invalid hex characters.
|
||||
init?(hexString: String) {
|
||||
var hex = hexString.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Remove optional 0x prefix
|
||||
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
|
||||
hex = String(hex.dropFirst(2))
|
||||
}
|
||||
|
||||
// Reject odd-length strings
|
||||
guard hex.count % 2 == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject empty strings
|
||||
guard !hex.isEmpty else {
|
||||
self = Data()
|
||||
return
|
||||
}
|
||||
|
||||
let len = hex.count / 2
|
||||
var data = Data(capacity: len)
|
||||
var index = hex.startIndex
|
||||
|
||||
for _ in 0..<len {
|
||||
let nextIndex = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
self = data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Binary Encoding Utilities
|
||||
|
||||
extension Data {
|
||||
// MARK: Writing
|
||||
|
||||
@inlinable public mutating func appendUInt8(_ value: UInt8) {
|
||||
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||
self.append(value)
|
||||
}
|
||||
|
||||
@@ -35,7 +90,7 @@ extension Data {
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func appendString(_ string: String, maxLength: Int = 255) {
|
||||
mutating func appendString(_ string: String, maxLength: Int = 255) {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
let length = Swift.min(data.count, maxLength)
|
||||
|
||||
@@ -48,7 +103,7 @@ extension Data {
|
||||
self.append(data.prefix(length))
|
||||
}
|
||||
|
||||
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
||||
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
||||
let length = Swift.min(data.count, maxLength)
|
||||
|
||||
if maxLength <= 255 {
|
||||
@@ -60,12 +115,12 @@ extension Data {
|
||||
self.append(data.prefix(length))
|
||||
}
|
||||
|
||||
public mutating func appendDate(_ date: Date) {
|
||||
mutating func appendDate(_ date: Date) {
|
||||
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
||||
self.appendUInt64(timestamp)
|
||||
}
|
||||
|
||||
public mutating func appendUUID(_ uuid: String) {
|
||||
mutating func appendUUID(_ uuid: String) {
|
||||
// Convert UUID string to 16 bytes
|
||||
var uuidData = Data(count: 16)
|
||||
|
||||
@@ -86,7 +141,7 @@ extension Data {
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
|
||||
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
|
||||
guard offset >= 0 && offset < self.count else { return nil }
|
||||
let value = self[offset]
|
||||
offset += 1
|
||||
@@ -120,7 +175,7 @@ extension Data {
|
||||
return value
|
||||
}
|
||||
|
||||
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
||||
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
||||
let length: Int
|
||||
|
||||
if maxLength <= 255 {
|
||||
@@ -139,7 +194,7 @@ extension Data {
|
||||
return String(data: stringData, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
||||
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
||||
let length: Int
|
||||
|
||||
if maxLength <= 255 {
|
||||
@@ -158,12 +213,12 @@ extension Data {
|
||||
return data
|
||||
}
|
||||
|
||||
public func readDate(at offset: inout Int) -> Date? {
|
||||
func readDate(at offset: inout Int) -> Date? {
|
||||
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
||||
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||
}
|
||||
|
||||
public func readUUID(at offset: inout Int) -> String? {
|
||||
func readUUID(at offset: inout Int) -> String? {
|
||||
guard offset + 16 <= self.count else { return nil }
|
||||
|
||||
let uuidData = self[offset..<offset + 16]
|
||||
@@ -184,7 +239,7 @@ extension Data {
|
||||
return result.uppercased()
|
||||
}
|
||||
|
||||
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
||||
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
||||
guard offset + count <= self.count else { return nil }
|
||||
|
||||
let data = self[offset..<offset + count]
|
||||
+26
-18
@@ -88,31 +88,40 @@
|
||||
/// - Platform-optimized byte swapping
|
||||
///
|
||||
|
||||
import struct Foundation.Data
|
||||
import class Foundation.NSData
|
||||
private import BitLogger
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
extension Data {
|
||||
func trimmingNullBytes() -> Data {
|
||||
// Find the first null byte
|
||||
if let nullIndex = self.firstIndex(of: 0) {
|
||||
return self.prefix(nullIndex)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||
/// Provides static methods for converting between BitchatPacket objects and
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
public struct BinaryProtocol {
|
||||
public static let v1HeaderSize = 14
|
||||
struct BinaryProtocol {
|
||||
static let v1HeaderSize = 14
|
||||
static let v2HeaderSize = 16
|
||||
public static let senderIDSize = 8
|
||||
public static let recipientIDSize = 8
|
||||
public static let signatureSize = 64
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
// Field offsets within packet header
|
||||
public struct Offsets {
|
||||
struct Offsets {
|
||||
static let version = 0
|
||||
static let type = 1
|
||||
static let ttl = 2
|
||||
static let timestamp = 3
|
||||
public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||
}
|
||||
|
||||
public static func headerSize(for version: UInt8) -> Int? {
|
||||
static func headerSize(for version: UInt8) -> Int? {
|
||||
switch version {
|
||||
case 1: return v1HeaderSize
|
||||
case 2: return v2HeaderSize
|
||||
@@ -124,11 +133,11 @@ public struct BinaryProtocol {
|
||||
return version == 2 ? 4 : 2
|
||||
}
|
||||
|
||||
public struct Flags {
|
||||
public static let hasRecipient: UInt8 = 0x01
|
||||
public static let hasSignature: UInt8 = 0x02
|
||||
public static let isCompressed: UInt8 = 0x04
|
||||
public static let hasRoute: UInt8 = 0x08
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
static let hasRoute: UInt8 = 0x08
|
||||
static let isRSR: UInt8 = 0x10
|
||||
}
|
||||
|
||||
@@ -257,7 +266,7 @@ public struct BinaryProtocol {
|
||||
}
|
||||
|
||||
// Decode binary data to BitchatPacket
|
||||
public static func decode(_ data: Data) -> BitchatPacket? {
|
||||
static func decode(_ data: Data) -> BitchatPacket? {
|
||||
// Try decode as-is first (robust when padding wasn't applied)
|
||||
if let pkt = decodeCore(data) { return pkt }
|
||||
// If that fails, try after removing padding
|
||||
@@ -334,7 +343,6 @@ public struct BinaryProtocol {
|
||||
}
|
||||
|
||||
guard payloadLength >= 0 else { return nil }
|
||||
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
|
||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||
|
||||
@@ -60,7 +60,40 @@
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
/// Simplified BitChat protocol message types.
|
||||
/// Reduced from 24 types to just 6 essential ones.
|
||||
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
||||
enum MessageType: UInt8 {
|
||||
// Public messages (unencrypted)
|
||||
case announce = 0x01 // "I'm here" with nickname
|
||||
case message = 0x02 // Public chat message
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||
|
||||
// Noise encryption
|
||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .requestSync: return "requestSync"
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Payload Types
|
||||
|
||||
@@ -98,6 +131,35 @@ enum LazyHandshakeState {
|
||||
case failed(Error) // Handshake failed
|
||||
}
|
||||
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
case read(by: String, at: Date) // Seen by recipient
|
||||
case failed(reason: String)
|
||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
return "Sent"
|
||||
case .delivered(let nickname, _):
|
||||
return "Delivered to \(nickname)"
|
||||
case .read(let nickname, _):
|
||||
return "Read by \(nickname)"
|
||||
case .failed(let reason):
|
||||
return "Failed: \(reason)"
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return "Delivered to \(reached)/\(total)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import Combine
|
||||
@@ -252,8 +251,7 @@ final class BLEService: NSObject {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true
|
||||
identityManager: SecureIdentityStateManagerProtocol
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
@@ -296,23 +294,22 @@ final class BLEService: NSObject {
|
||||
// Tag BLE queue for re-entrancy detection
|
||||
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
||||
|
||||
if initializeBluetoothManagers {
|
||||
// Initialize BLE on background queue to prevent main thread blocking.
|
||||
#if os(iOS)
|
||||
let centralOptions: [String: Any] = [
|
||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||
]
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||
// Initialize BLE on background queue to prevent main thread blocking
|
||||
// This prevents app freezes during BLE operations
|
||||
#if os(iOS)
|
||||
let centralOptions: [String: Any] = [
|
||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||
]
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||
|
||||
let peripheralOptions: [String: Any] = [
|
||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||
]
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
||||
#else
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
}
|
||||
let peripheralOptions: [String: Any] = [
|
||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||
]
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
||||
#else
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
@@ -526,7 +523,7 @@ final class BLEService: NSObject {
|
||||
|
||||
func stopServices() {
|
||||
// Send leave message synchronously to ensure delivery
|
||||
var leavePacket = BitchatPacket(
|
||||
let leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
@@ -536,10 +533,6 @@ final class BLEService: NSObject {
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
if let signed = noiseService.signPacket(leavePacket) {
|
||||
leavePacket = signed
|
||||
}
|
||||
|
||||
// Send immediately to all connected peers (synchronized access to BLE state)
|
||||
if let data = leavePacket.toBinaryData(padding: false) {
|
||||
let leavePriority = priority(for: leavePacket, data: data)
|
||||
@@ -736,7 +729,7 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
var packet = BitchatPacket(
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
recipientID: nil,
|
||||
@@ -747,13 +740,6 @@ final class BLEService: NSObject {
|
||||
version: 2
|
||||
)
|
||||
|
||||
if let signed = self.noiseService.signPacket(packet) {
|
||||
packet = signed
|
||||
} else {
|
||||
SecureLogger.error("❌ Failed to sign file broadcast packet", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let senderHex = packet.senderID.hexEncodedString()
|
||||
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
@@ -1223,16 +1209,38 @@ final class BLEService: NSObject {
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
enforceIncomingFilesQuota(reservingBytes: filePacket.content.count)
|
||||
|
||||
let fallbackExt = mime.defaultExtension
|
||||
let subdirectory: String
|
||||
switch mime.category {
|
||||
case .audio:
|
||||
subdirectory = "voicenotes/incoming"
|
||||
case .image:
|
||||
subdirectory = "images/incoming"
|
||||
case .file:
|
||||
subdirectory = "files/incoming"
|
||||
}
|
||||
|
||||
guard let destination = saveIncomingFile(
|
||||
data: filePacket.content,
|
||||
preferredName: filePacket.fileName,
|
||||
subdirectory: "\(mime.category.mediaDir)/incoming",
|
||||
fallbackExtension: mime.defaultExtension,
|
||||
subdirectory: subdirectory,
|
||||
fallbackExtension: fallbackExt,
|
||||
defaultPrefix: mime.category.rawValue
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let marker: String
|
||||
let fileName = destination.lastPathComponent
|
||||
switch mime.category {
|
||||
case .audio:
|
||||
marker = "[voice] \(fileName)"
|
||||
case .image:
|
||||
marker = "[image] \(fileName)"
|
||||
case .file:
|
||||
marker = "[file] \(fileName)"
|
||||
}
|
||||
|
||||
let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID
|
||||
|
||||
if isPrivateMessage {
|
||||
@@ -1242,7 +1250,7 @@ final class BLEService: NSObject {
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
let message = BitchatMessage(
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
content: marker,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -1355,7 +1363,7 @@ final class BLEService: NSObject {
|
||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
|
||||
|
||||
candidate = candidate.trimmed
|
||||
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if candidate.isEmpty { candidate = defaultName }
|
||||
|
||||
// Security: Reject dotfiles (hidden file attacks)
|
||||
@@ -2030,28 +2038,26 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
|
||||
if preseedPeer {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
peerID: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
}
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
peerID: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
}
|
||||
}
|
||||
handleReceivedPacket(packet, from: fromPeerID)
|
||||
@@ -3833,7 +3839,7 @@ extension BLEService {
|
||||
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)
|
||||
if derivedFromKey != peerID {
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
// Don't add ourselves as a peer
|
||||
|
||||
@@ -191,22 +191,5 @@ enum MimeType: CaseIterable, Hashable {
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
case audio, image, file
|
||||
|
||||
/// Ends with a space
|
||||
var messagePrefix: String {
|
||||
switch self {
|
||||
case .audio: "[voice] "
|
||||
case .image: "[image] "
|
||||
case .file: "[file] "
|
||||
}
|
||||
}
|
||||
|
||||
var mediaDir: String {
|
||||
switch self {
|
||||
case .audio: "voicenotes"
|
||||
case .image: "images"
|
||||
case .file: "files"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// Result of command processing
|
||||
enum CommandResult {
|
||||
@@ -167,7 +166,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(command) <nickname>")
|
||||
}
|
||||
@@ -210,7 +209,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleBlock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
@@ -285,7 +284,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
}
|
||||
@@ -312,7 +311,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
|
||||
@@ -13,25 +13,6 @@ import Combine
|
||||
import BitLogger
|
||||
import Tor
|
||||
|
||||
protocol GeohashPresenceTimerProtocol: AnyObject {
|
||||
var isValid: Bool { get }
|
||||
func invalidate()
|
||||
}
|
||||
|
||||
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
|
||||
private let base: Timer
|
||||
|
||||
init(base: Timer) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
var isValid: Bool { base.isValid }
|
||||
|
||||
func invalidate() {
|
||||
base.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||
///
|
||||
/// Behavior:
|
||||
@@ -44,27 +25,18 @@ final class GeohashPresenceService: ObservableObject {
|
||||
static let shared = GeohashPresenceService()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||
private let torIsReady: () -> Bool
|
||||
private let torIsForeground: () -> Bool
|
||||
private let deriveIdentity: (String) throws -> NostrIdentity
|
||||
private let relayLookup: (String, Int) -> [String]
|
||||
private let relaySender: (NostrEvent, [String]) -> Void
|
||||
private let sleeper: (UInt64) async -> Void
|
||||
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
|
||||
private var heartbeatTimer: Timer?
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
// Loop interval range in seconds
|
||||
private let loopMinInterval: TimeInterval
|
||||
private let loopMaxInterval: TimeInterval
|
||||
private let loopMinInterval: TimeInterval = 40.0
|
||||
private let loopMaxInterval: TimeInterval = 80.0
|
||||
|
||||
// Per-broadcast decorrelation delay range in seconds
|
||||
private let burstMinDelay: TimeInterval
|
||||
private let burstMaxDelay: TimeInterval
|
||||
private let burstMinDelay: TimeInterval = 2.0
|
||||
private let burstMaxDelay: TimeInterval = 5.0
|
||||
|
||||
// Privacy: Only broadcast to these levels
|
||||
private let allowedPrecisions: Set<Int> = [
|
||||
@@ -74,74 +46,6 @@ final class GeohashPresenceService: ObservableObject {
|
||||
]
|
||||
|
||||
private init() {
|
||||
let idBridge = NostrIdentityBridge()
|
||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
self.torIsReady = { TorManager.shared.isReady }
|
||||
self.torIsForeground = { TorManager.shared.isForeground() }
|
||||
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
|
||||
self.relayLookup = { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
}
|
||||
self.relaySender = { event, relays in
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
}
|
||||
self.sleeper = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
self.scheduleTimer = { interval, action in
|
||||
GeohashPresenceTimerAdapter(
|
||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
action()
|
||||
}
|
||||
)
|
||||
}
|
||||
self.loopMinInterval = 40.0
|
||||
self.loopMaxInterval = 80.0
|
||||
self.burstMinDelay = 2.0
|
||||
self.burstMaxDelay = 5.0
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
internal init(
|
||||
availableChannelsProvider: @escaping () -> [GeohashChannel],
|
||||
locationChanges: AnyPublisher<[GeohashChannel], Never>,
|
||||
torReadyPublisher: AnyPublisher<Void, Never>,
|
||||
torIsReady: @escaping () -> Bool,
|
||||
torIsForeground: @escaping () -> Bool,
|
||||
deriveIdentity: @escaping (String) throws -> NostrIdentity,
|
||||
relayLookup: @escaping (String, Int) -> [String],
|
||||
relaySender: @escaping (NostrEvent, [String]) -> Void,
|
||||
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
|
||||
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
|
||||
GeohashPresenceTimerAdapter(
|
||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
action()
|
||||
}
|
||||
)
|
||||
},
|
||||
loopMinInterval: TimeInterval = 40.0,
|
||||
loopMaxInterval: TimeInterval = 80.0,
|
||||
burstMinDelay: TimeInterval = 2.0,
|
||||
burstMaxDelay: TimeInterval = 5.0
|
||||
) {
|
||||
self.availableChannelsProvider = availableChannelsProvider
|
||||
self.locationChanges = locationChanges
|
||||
self.torReadyPublisher = torReadyPublisher
|
||||
self.torIsReady = torIsReady
|
||||
self.torIsForeground = torIsForeground
|
||||
self.deriveIdentity = deriveIdentity
|
||||
self.relayLookup = relayLookup
|
||||
self.relaySender = relaySender
|
||||
self.sleeper = sleeper
|
||||
self.scheduleTimer = scheduleTimer
|
||||
self.loopMinInterval = loopMinInterval
|
||||
self.loopMaxInterval = loopMaxInterval
|
||||
self.burstMinDelay = burstMinDelay
|
||||
self.burstMaxDelay = burstMaxDelay
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
@@ -153,7 +57,7 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
locationChanges
|
||||
LocationStateManager.shared.$availableChannels
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.handleLocationChange()
|
||||
@@ -161,28 +65,28 @@ final class GeohashPresenceService: ObservableObject {
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||
torReadyPublisher
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleConnectivityChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func handleLocationChange() {
|
||||
private func handleLocationChange() {
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnectivityChange() {
|
||||
private func handleConnectivityChange() {
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
@@ -190,33 +94,33 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleNextHeartbeat() {
|
||||
private func scheduleNextHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performHeartbeat() {
|
||||
private func performHeartbeat() {
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
|
||||
// 1. Check preconditions
|
||||
guard torIsReady() else {
|
||||
guard TorManager.shared.isReady else {
|
||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||
if !torIsForeground() {
|
||||
if !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Get channels
|
||||
let channels = availableChannelsProvider()
|
||||
let channels = LocationStateManager.shared.availableChannels
|
||||
guard !channels.isEmpty else { return }
|
||||
|
||||
// 3. Filter and broadcast
|
||||
@@ -232,16 +136,16 @@ final class GeohashPresenceService: ObservableObject {
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
await self.sleeper(nanoseconds)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastPresence(for geohash: String) {
|
||||
private func broadcastPresence(for geohash: String) {
|
||||
do {
|
||||
guard let identity = try? deriveIdentity(geohash) else {
|
||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -251,10 +155,13 @@ final class GeohashPresenceService: ObservableObject {
|
||||
)
|
||||
|
||||
// Send via RelayManager
|
||||
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
if !targetRelays.isEmpty {
|
||||
relaySender(event, targetRelays)
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -7,10 +7,76 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// MARK: - Keychain Error Types
|
||||
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
|
||||
|
||||
/// Result of a keychain read operation with proper error classification
|
||||
enum KeychainReadResult {
|
||||
case success(Data)
|
||||
case itemNotFound // Expected: key doesn't exist yet
|
||||
case accessDenied // Critical: app lacks keychain access
|
||||
case deviceLocked // Recoverable: device is locked
|
||||
case authenticationFailed // Recoverable: biometric/passcode failed
|
||||
case otherError(OSStatus) // Unexpected error
|
||||
|
||||
var isRecoverableError: Bool {
|
||||
switch self {
|
||||
case .deviceLocked, .authenticationFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a keychain save operation with proper error classification
|
||||
enum KeychainSaveResult {
|
||||
case success
|
||||
case duplicateItem // Can retry with update
|
||||
case accessDenied // Critical: app lacks keychain access
|
||||
case deviceLocked // Recoverable: device is locked
|
||||
case storageFull // Critical: no space available
|
||||
case otherError(OSStatus)
|
||||
|
||||
var isRecoverableError: Bool {
|
||||
switch self {
|
||||
case .duplicateItem, .deviceLocked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol KeychainManagerProtocol {
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||
func getIdentityKey(forKey key: String) -> Data?
|
||||
func deleteIdentityKey(forKey key: String) -> Bool
|
||||
func deleteAllKeychainData() -> Bool
|
||||
|
||||
func secureClear(_ data: inout Data)
|
||||
func secureClear(_ string: inout String)
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool
|
||||
|
||||
// BCH-01-009: Methods with proper error classification
|
||||
/// Get identity key with detailed result for error handling
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
|
||||
/// Save identity key with detailed result for error handling
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
/// Load data from a custom service
|
||||
func load(key: String, service: String) -> Data?
|
||||
/// Delete data from a custom service
|
||||
func delete(key: String, service: String)
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
|
||||
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
var displayName: String {
|
||||
let suffix = String(pubkey.suffix(4))
|
||||
if let nick = nickname?.trimmedOrNilIfEmpty {
|
||||
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "\(nick)#\(suffix)"
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
@@ -199,7 +199,8 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
/// Send a location note for the current geohash using the per-geohash identity.
|
||||
func send(content: String, nickname: String) {
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
|
||||
@@ -5,79 +5,6 @@ import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
protocol LocationStateManaging: AnyObject {
|
||||
var delegate: CLLocationManagerDelegate? { get set }
|
||||
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||
var distanceFilter: CLLocationDistance { get set }
|
||||
var authorizationStatus: CLAuthorizationStatus { get }
|
||||
func requestWhenInUseAuthorization()
|
||||
func requestLocation()
|
||||
func startUpdatingLocation()
|
||||
func stopUpdatingLocation()
|
||||
}
|
||||
|
||||
protocol LocationStateGeocoding: AnyObject {
|
||||
func cancelGeocode()
|
||||
func reverseGeocodeLocation(
|
||||
_ location: CLLocation,
|
||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
|
||||
private let base = CLLocationManager()
|
||||
|
||||
var delegate: CLLocationManagerDelegate? {
|
||||
get { base.delegate }
|
||||
set { base.delegate = newValue }
|
||||
}
|
||||
|
||||
var desiredAccuracy: CLLocationAccuracy {
|
||||
get { base.desiredAccuracy }
|
||||
set { base.desiredAccuracy = newValue }
|
||||
}
|
||||
|
||||
var distanceFilter: CLLocationDistance {
|
||||
get { base.distanceFilter }
|
||||
set { base.distanceFilter = newValue }
|
||||
}
|
||||
|
||||
var authorizationStatus: CLAuthorizationStatus {
|
||||
base.authorizationStatus
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
base.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
base.requestLocation()
|
||||
}
|
||||
|
||||
func startUpdatingLocation() {
|
||||
base.startUpdatingLocation()
|
||||
}
|
||||
|
||||
func stopUpdatingLocation() {
|
||||
base.stopUpdatingLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private final class CLGeocoderAdapter: LocationStateGeocoding {
|
||||
private let base = CLGeocoder()
|
||||
|
||||
func cancelGeocode() {
|
||||
base.cancelGeocode()
|
||||
}
|
||||
|
||||
func reverseGeocodeLocation(
|
||||
_ location: CLLocation,
|
||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||
) {
|
||||
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified manager for location-based channel state including:
|
||||
/// - CoreLocation permissions and one-shot location retrieval
|
||||
/// - Geohash channel computation from coordinates
|
||||
@@ -99,8 +26,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
|
||||
// MARK: - Private Properties (CoreLocation)
|
||||
|
||||
private let cl: LocationStateManaging
|
||||
private let geocoder: LocationStateGeocoding
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private var isGeocoding: Bool = false
|
||||
@@ -146,8 +73,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
|
||||
private override init() {
|
||||
self.storage = .standard
|
||||
self.cl = CLLocationManagerAdapter()
|
||||
self.geocoder = CLGeocoderAdapter()
|
||||
super.init()
|
||||
|
||||
// Skip CoreLocation setup in test environments
|
||||
@@ -167,30 +92,10 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
/// Internal initializer for testing with custom storage
|
||||
init(storage: UserDefaults) {
|
||||
self.storage = storage
|
||||
self.cl = CLLocationManagerAdapter()
|
||||
self.geocoder = CLGeocoderAdapter()
|
||||
super.init()
|
||||
loadPersistedState()
|
||||
}
|
||||
|
||||
internal init(
|
||||
storage: UserDefaults,
|
||||
locationManager: LocationStateManaging,
|
||||
geocoder: LocationStateGeocoding,
|
||||
shouldInitializeCoreLocation: Bool
|
||||
) {
|
||||
self.storage = storage
|
||||
self.cl = locationManager
|
||||
self.geocoder = geocoder
|
||||
super.init()
|
||||
loadPersistedState()
|
||||
guard shouldInitializeCoreLocation else { return }
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
initializePermissionState()
|
||||
}
|
||||
|
||||
private func loadPersistedState() {
|
||||
// Load selected channel
|
||||
if let data = storage.data(forKey: selectedChannelKey),
|
||||
@@ -227,7 +132,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
}
|
||||
|
||||
private func initializePermissionState() {
|
||||
let status = cl.authorizationStatus
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
|
||||
// Fall back to persisted teleport state if no location authorization
|
||||
@@ -246,7 +156,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
// MARK: - Public API (Permissions & Location)
|
||||
|
||||
func enableLocationChannels() {
|
||||
let status = cl.authorizationStatus
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
@@ -597,7 +512,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmed
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
|
||||
@@ -145,7 +145,7 @@ enum ContentNormalizer {
|
||||
}
|
||||
|
||||
// Trim and collapse whitespace
|
||||
let trimmed = simplified.trimmed
|
||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
|
||||
// Take prefix and hash
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
|
||||
@@ -3,27 +3,6 @@ import BitLogger
|
||||
import Combine
|
||||
import Tor
|
||||
|
||||
@MainActor
|
||||
protocol NetworkActivationTorControlling: AnyObject {
|
||||
func setAutoStartAllowed(_ allowed: Bool)
|
||||
func startIfNeeded()
|
||||
func shutdownCompletely()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol NetworkActivationRelayControlling: AnyObject {
|
||||
func connect()
|
||||
func disconnect()
|
||||
}
|
||||
|
||||
protocol NetworkActivationProxyControlling: AnyObject {
|
||||
func setProxyMode(useTor: Bool)
|
||||
}
|
||||
|
||||
extension TorManager: NetworkActivationTorControlling {}
|
||||
extension NostrRelayManager: NetworkActivationRelayControlling {}
|
||||
extension TorURLSession: NetworkActivationProxyControlling {}
|
||||
|
||||
/// 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.
|
||||
@@ -38,55 +17,14 @@ final class NetworkActivationService: ObservableObject {
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
private var torAutoStartDesired: Bool = false
|
||||
private let storage: UserDefaults
|
||||
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let torController: NetworkActivationTorControlling
|
||||
private let relayController: NetworkActivationRelayControlling
|
||||
private let proxyController: NetworkActivationProxyControlling
|
||||
private let notificationCenter: NotificationCenter
|
||||
|
||||
private init() {
|
||||
storage = .standard
|
||||
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
||||
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
torController = TorManager.shared
|
||||
relayController = NostrRelayManager.shared
|
||||
proxyController = TorURLSession.shared
|
||||
notificationCenter = .default
|
||||
}
|
||||
|
||||
internal init(
|
||||
storage: UserDefaults,
|
||||
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
|
||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||
torController: NetworkActivationTorControlling,
|
||||
relayController: NetworkActivationRelayControlling,
|
||||
proxyController: NetworkActivationProxyControlling,
|
||||
notificationCenter: NotificationCenter = .default
|
||||
) {
|
||||
self.storage = storage
|
||||
self.locationPermissionPublisher = locationPermissionPublisher
|
||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.torController = torController
|
||||
self.relayController = relayController
|
||||
self.proxyController = proxyController
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
@@ -96,16 +34,16 @@ final class NetworkActivationService: ObservableObject {
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||
applyTorState(torDesired: torAutoStartDesired)
|
||||
if allowed {
|
||||
relayController.connect()
|
||||
NostrRelayManager.shared.connect()
|
||||
} else {
|
||||
relayController.disconnect()
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
|
||||
// React to location permission changes
|
||||
locationPermissionPublisher
|
||||
LocationChannelManager.shared.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
@@ -113,7 +51,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
|
||||
// React to mutual favorites changes
|
||||
mutualFavoritesPublisher
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
@@ -124,8 +62,8 @@ final class NetworkActivationService: ObservableObject {
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
storage.set(enabled, forKey: torPreferenceKey)
|
||||
notificationCenter.post(
|
||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||
NotificationCenter.default.post(
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
userInfo: ["enabled": enabled]
|
||||
@@ -144,33 +82,33 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
if statusChanged || torChanged {
|
||||
torAutoStartDesired = torDesired
|
||||
torController.setAutoStartAllowed(torDesired)
|
||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||
applyTorState(torDesired: torDesired)
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if torChanged {
|
||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||
relayController.disconnect()
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
relayController.connect()
|
||||
NostrRelayManager.shared.connect()
|
||||
} else if statusChanged {
|
||||
relayController.disconnect()
|
||||
NostrRelayManager.shared.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
let permOK = permissionProvider() == .authorized
|
||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
return permOK || hasMutual
|
||||
}
|
||||
|
||||
private func applyTorState(torDesired: Bool) {
|
||||
proxyController.setProxyMode(useTor: torDesired)
|
||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||
if torDesired {
|
||||
torController.startIfNeeded()
|
||||
TorManager.shared.startIfNeeded()
|
||||
} else {
|
||||
torController.shutdownCompletely()
|
||||
TorManager.shared.shutdownCompletely()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,6 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Noise
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport, @unchecked Sendable {
|
||||
struct Dependencies {
|
||||
let notificationCenter: NotificationCenter
|
||||
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
loadFavorites: { FavoritesPersistenceService.shared.favorites },
|
||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
@@ -44,27 +17,20 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let dependencies: Dependencies
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
|
||||
setupObservers()
|
||||
|
||||
// Synchronously warm the cache to avoid startup race
|
||||
let favorites = self.dependencies.loadFavorites()
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
@@ -74,14 +40,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let favoriteStatusObserver {
|
||||
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
queue: nil
|
||||
@@ -92,7 +52,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
private func refreshReachablePeers() {
|
||||
Task { @MainActor in
|
||||
let favorites = dependencies.loadFavorites()
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
@@ -160,7 +120,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
@@ -183,7 +143,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
@@ -199,7 +159,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
@@ -269,9 +229,9 @@ extension NostrTransport {
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
dependencies.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
}
|
||||
dependencies.sendEvent(event)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
|
||||
/// Must be called within a barrier on `queue`
|
||||
@@ -289,7 +249,7 @@ extension NostrTransport {
|
||||
defer { scheduleNextReadAck() }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
@@ -300,7 +260,7 @@ extension NostrTransport {
|
||||
}
|
||||
|
||||
private func scheduleNextReadAck() {
|
||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||
self?.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.isSendingReadAcks = false
|
||||
self?.processReadQueueIfNeeded()
|
||||
@@ -311,12 +271,12 @@ extension NostrTransport {
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.id.count == 16,
|
||||
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
#if os(iOS)
|
||||
@@ -15,103 +14,24 @@ import UIKit
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
protocol NotificationAuthorizing {
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
protocol NotificationRequestDelivering {
|
||||
func add(_ request: UNNotificationRequest)
|
||||
}
|
||||
|
||||
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
) {
|
||||
center.requestAuthorization(options: options, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) {
|
||||
Task {
|
||||
try? await center.add(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
||||
func requestAuthorization(
|
||||
options: UNAuthorizationOptions,
|
||||
completionHandler: @escaping (Bool, Error?) -> Void
|
||||
) {
|
||||
completionHandler(false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
|
||||
func add(_ request: UNNotificationRequest) {}
|
||||
}
|
||||
|
||||
final class NotificationService {
|
||||
static let shared = NotificationService()
|
||||
|
||||
private let isRunningTestsProvider: () -> Bool
|
||||
private let authorizer: NotificationAuthorizing
|
||||
private let requestDeliverer: NotificationRequestDelivering
|
||||
|
||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||
private var isRunningTests: Bool {
|
||||
isRunningTestsProvider()
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}
|
||||
|
||||
private init() {
|
||||
self.isRunningTestsProvider = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}
|
||||
if isRunningTestsProvider() {
|
||||
self.authorizer = NoopNotificationAuthorizer()
|
||||
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
||||
} else {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
||||
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
||||
}
|
||||
}
|
||||
|
||||
internal init(
|
||||
isRunningTestsProvider: @escaping () -> Bool,
|
||||
authorizer: NotificationAuthorizing,
|
||||
requestDeliverer: NotificationRequestDelivering
|
||||
) {
|
||||
self.isRunningTestsProvider = isRunningTestsProvider
|
||||
self.authorizer = authorizer
|
||||
self.requestDeliverer = requestDeliverer
|
||||
}
|
||||
private init() {}
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if granted {
|
||||
// Permission granted
|
||||
} else {
|
||||
@@ -144,7 +64,7 @@ final class NotificationService {
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
requestDeliverer.add(request)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
@@ -176,8 +96,7 @@ final class NotificationService {
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
||||
let identifier = "network-available"
|
||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||
|
||||
sendLocalNotification(
|
||||
title: title,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ enum TransportConfig {
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
|
||||
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||
|
||||
// BLE duty/announce/connect
|
||||
@@ -133,6 +132,9 @@ enum TransportConfig {
|
||||
static let nostrShortKeyDisplayLength: Int = 8
|
||||
static let nostrConvKeyPrefixLength: Int = 16
|
||||
|
||||
// Compression
|
||||
static let compressionThresholdBytes: Int = 100
|
||||
|
||||
// Message deduplication
|
||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||
static let messageDedupMaxCount: Int = 1000
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
|
||||
// Gossip-based sync manager using on-demand GCS filters
|
||||
final class GossipSyncManager {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import struct BitFoundation.BitchatPacket
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import Foundation
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
|
||||
/// Manages outgoing sync requests and validates incoming responses.
|
||||
///
|
||||
@@ -18,21 +17,14 @@ final class RequestSyncManager {
|
||||
|
||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||
private let responseWindow: TimeInterval
|
||||
private let now: () -> TimeInterval
|
||||
|
||||
init(
|
||||
responseWindow: TimeInterval = 30.0,
|
||||
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
|
||||
) {
|
||||
self.responseWindow = responseWindow
|
||||
self.now = now
|
||||
}
|
||||
// Allow responses for 30s after request
|
||||
private let responseWindow: TimeInterval = 30.0
|
||||
|
||||
/// Register that we are sending a sync request to a peer.
|
||||
/// - Parameter peerID: The peer we are requesting sync from
|
||||
func registerRequest(to peerID: PeerID) {
|
||||
let now = self.now()
|
||||
let now = Date().timeIntervalSince1970
|
||||
queue.async(flags: .barrier) {
|
||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||
self.pendingRequests[peerID] = now
|
||||
@@ -54,7 +46,7 @@ final class RequestSyncManager {
|
||||
return false
|
||||
}
|
||||
|
||||
let now = self.now()
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - requestTime > responseWindow {
|
||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
||||
// We don't remove here because we might receive multiple packets for one request
|
||||
@@ -67,7 +59,7 @@ final class RequestSyncManager {
|
||||
|
||||
/// Periodic cleanup of expired requests
|
||||
func cleanup() {
|
||||
let now = self.now()
|
||||
let now = Date().timeIntervalSince1970
|
||||
queue.async(flags: .barrier) {
|
||||
let originalCount = self.pendingRequests.count
|
||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||
@@ -79,8 +71,4 @@ final class RequestSyncManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var debugPendingRequestCount: Int {
|
||||
queue.sync { pendingRequests.count }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||
|
||||
+3
-3
@@ -6,12 +6,12 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
private import Compression
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
struct CompressionUtil {
|
||||
// Compression threshold - don't compress if data is smaller than this
|
||||
static let compressionThreshold = Constants.compressionThresholdBytes // bytes
|
||||
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
|
||||
|
||||
// Compress data using zlib algorithm (most compatible)
|
||||
static func compress(_ data: Data) -> Data? {
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Data+SHA256.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 26/09/2025.
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
import struct CryptoKit.SHA256
|
||||
|
||||
extension Data {
|
||||
/// Returns the hex representation of SHA256 hash
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
sha256Hash().hexEncodedString()
|
||||
}
|
||||
|
||||
/// Returns the SHA256 hash wrapped in Data
|
||||
func sha256Hash() -> Data {
|
||||
Data(SHA256.hash(data: self))
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -1,13 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||
public enum FileTransferLimits {
|
||||
enum FileTransferLimits {
|
||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Voice notes stay small for low-latency relays.
|
||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
public static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||
public static let maxFramedFileBytes: Int = {
|
||||
static let maxFramedFileBytes: Int = {
|
||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||
@@ -17,7 +19,7 @@ public enum FileTransferLimits {
|
||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||
}()
|
||||
|
||||
public static func isValidPayload(_ size: Int) -> Bool {
|
||||
static func isValidPayload(_ size: Int) -> Bool {
|
||||
size <= maxPayloadBytes
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ struct InputValidator {
|
||||
/// Rejects strings containing control characters to prevent potential security issues
|
||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Reject control characters outright instead of rewriting the string.
|
||||
// This prevents injection attacks and ensures consistent UI rendering.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
|
||||
struct PeerDisplayNameResolver {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// SystemSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum SystemSettings {
|
||||
case bluetooth
|
||||
case location
|
||||
case microphone
|
||||
|
||||
#if os(macOS)
|
||||
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
|
||||
|
||||
private var macPrivacyAnchor: String {
|
||||
switch self {
|
||||
case .bluetooth: "Privacy_Bluetooth"
|
||||
case .location: "Privacy_LocationServices"
|
||||
case .microphone: "Privacy_Microphone"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func open() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,6 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
@@ -145,11 +144,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
|
||||
// Trim whitespace whenever nickname is set
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed != nickname {
|
||||
nickname = trimmed
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
if !meshService.myPeerID.isEmpty {
|
||||
@@ -749,7 +747,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
private func loadNickname() {
|
||||
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
|
||||
nickname = savedNickname.trimmed
|
||||
// Trim whitespace when loading
|
||||
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else {
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
@@ -765,10 +764,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
}
|
||||
|
||||
func validateAndSaveNickname() {
|
||||
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
|
||||
// Trim whitespace from nickname
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Check if nickname is empty after trimming
|
||||
if trimmed.isEmpty {
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
} else {
|
||||
nickname = trimmed
|
||||
}
|
||||
saveNickname()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Favorites Management
|
||||
|
||||
// MARK: - Blocked Users Management (Delegated to PeerStateManager)
|
||||
|
||||
|
||||
@@ -989,7 +998,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
@MainActor
|
||||
func sendMessage(_ content: String) {
|
||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
// Check for commands
|
||||
if content.hasPrefix("/") {
|
||||
@@ -1800,15 +1810,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
if let peerID {
|
||||
return getPrivateChatMessages(for: peerID)
|
||||
} else {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
var combined: [BitchatMessage] = []
|
||||
@@ -3000,7 +3002,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
Task { @MainActor in
|
||||
// Early validation
|
||||
guard !isMessageBlocked(message) else { return }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
||||
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
|
||||
|
||||
// Route to appropriate handler
|
||||
if message.isPrivate {
|
||||
@@ -3173,7 +3175,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
Task { @MainActor in
|
||||
let normalized = content.trimmed
|
||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let publicMentions = parseMentions(from: normalized)
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
@@ -3346,25 +3348,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
self.scheduleNetworkEmptyTimer()
|
||||
} else {
|
||||
self.invalidateNetworkEmptyTimer()
|
||||
// Don't trim recentlySeenPeers here - let timers handle cleanup.
|
||||
// Trimming immediately causes peers to be treated as "new" when they
|
||||
// briefly drop and reconnect, triggering notification floods.
|
||||
// Trim out peers we no longer observe before comparing for new arrivals
|
||||
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||
|
||||
|
||||
if !newPeers.isEmpty {
|
||||
// Rate limit: max one notification per 5 minutes
|
||||
let cooldown = TransportConfig.networkNotificationCooldownSeconds
|
||||
if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {
|
||||
// Only mark peers as seen when we actually notify about them
|
||||
// This ensures peers arriving during cooldown will be included in the next notification
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
self.scheduleNetworkResetTimer()
|
||||
}
|
||||
}
|
||||
@@ -3781,8 +3776,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
// Removed background nudge notification for generic "new chats!"
|
||||
|
||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||
if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
publicMessagePipeline.enqueue(finalMessage)
|
||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
publicMessagePipeline.enqueue(finalMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
|
||||
@@ -57,7 +56,6 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
@@ -78,7 +76,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmed
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
@@ -116,8 +114,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmed
|
||||
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
@@ -148,12 +146,13 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -193,7 +192,7 @@ extension ChatViewModel {
|
||||
case .mesh:
|
||||
refreshVisibleMessages(from: .mesh)
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
@@ -255,7 +254,6 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
@@ -309,7 +307,8 @@ extension ChatViewModel {
|
||||
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
@@ -325,10 +324,8 @@ extension ChatViewModel {
|
||||
let content = event.content
|
||||
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||
teleTag.count >= 2,
|
||||
teleTag[1] == "teleport",
|
||||
content.trimmed.isEmpty {
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -370,7 +367,6 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
@@ -384,7 +380,9 @@ extension ChatViewModel {
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
guard content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -487,7 +485,6 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
@@ -498,8 +495,9 @@ extension ChatViewModel {
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
|
||||
@@ -604,7 +602,6 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
do {
|
||||
@@ -622,7 +619,8 @@ extension ChatViewModel {
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -633,23 +631,24 @@ extension ChatViewModel {
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -802,19 +801,6 @@ extension ChatViewModel {
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
||||
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
|
||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
||||
guard encoded.count <= maxEncoded else { return nil }
|
||||
guard let packetData = Self.base64URLDecode(encoded),
|
||||
packetData.count <= maxBytes
|
||||
else { return nil }
|
||||
return BitchatPacket.from(packetData)
|
||||
}
|
||||
|
||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
extension ChatViewModel {
|
||||
@@ -320,7 +319,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
|
||||
@@ -365,36 +364,6 @@ extension ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run {
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
||||
guard canSendMediaInCurrentContext else {
|
||||
@@ -429,7 +398,7 @@ extension ChatViewModel {
|
||||
)
|
||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
@@ -548,19 +517,20 @@ extension ChatViewModel {
|
||||
|
||||
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
// Check both outgoing and incoming directories for thorough cleanup
|
||||
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||
let base = try? applicationFilesDirectory(),
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||
safeFilename != "." && safeFilename != ".."
|
||||
else {
|
||||
return
|
||||
}
|
||||
let prefixes = ["[voice] ", "[image] ", "[file] "]
|
||||
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
|
||||
"images/outgoing", "images/incoming",
|
||||
"files/outgoing", "files/incoming"]
|
||||
|
||||
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
|
||||
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
|
||||
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent
|
||||
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
|
||||
|
||||
// Try all possible locations (outgoing and incoming)
|
||||
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
||||
for subdir in subdirs {
|
||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// Maintains mesh and geohash public timelines with simple caps and helpers.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct PublicTimelineStore {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//
|
||||
// VoiceRecordingViewModel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class VoiceRecordingViewModel: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case requestingPermission
|
||||
case permissionDenied
|
||||
case preparing
|
||||
case recording(startDate: Date)
|
||||
case error(message: String)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .recording: true
|
||||
case .idle, .requestingPermission, .permissionDenied, .error: false
|
||||
}
|
||||
}
|
||||
|
||||
var alertMessage: String {
|
||||
switch self {
|
||||
case .error(let message): message
|
||||
case .permissionDenied: "Microphone access is required to record voice notes."
|
||||
case .idle, .requestingPermission, .preparing, .recording: ""
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func duration(for date: Date) -> TimeInterval {
|
||||
switch self {
|
||||
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
|
||||
case .recording(let startDate): date.timeIntervalSince(startDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showAlert: Bool {
|
||||
get {
|
||||
switch state {
|
||||
case .permissionDenied, .error: true
|
||||
case .idle, .requestingPermission, .preparing, .recording: false
|
||||
}
|
||||
}
|
||||
set {
|
||||
if !newValue { state = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var state = State.idle
|
||||
|
||||
func formattedDuration(for date: Date) -> String {
|
||||
let clamped = max(0, state.duration(for: date))
|
||||
let totalMilliseconds = Int(clamped * 1000)
|
||||
let minutes = totalMilliseconds / 60_000
|
||||
let seconds = (totalMilliseconds % 60_000) / 1_000
|
||||
let centiseconds = (totalMilliseconds % 1_000) / 10
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func start(shouldShow: Bool) {
|
||||
guard shouldShow, state == .idle else { return }
|
||||
state = .requestingPermission
|
||||
Task {
|
||||
let granted = await VoiceRecorder.shared.requestPermission()
|
||||
guard state == .requestingPermission else { return }
|
||||
guard granted else {
|
||||
state = .permissionDenied
|
||||
return
|
||||
}
|
||||
state = .preparing
|
||||
do {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
guard state == .preparing else {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
state = .recording(startDate: Date())
|
||||
} catch {
|
||||
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
guard state == .preparing else { return }
|
||||
state = .error(message: "Could not start recording.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish(completion: ((URL) -> Void)?) {
|
||||
let previousState = state
|
||||
|
||||
switch previousState {
|
||||
case .permissionDenied, .error:
|
||||
return
|
||||
case .idle, .requestingPermission, .preparing, .recording:
|
||||
break
|
||||
}
|
||||
|
||||
state = .idle
|
||||
|
||||
guard case .recording(let startDate) = previousState, let completion else {
|
||||
Task { await VoiceRecorder.shared.cancelRecording() }
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let finalDuration = Date().timeIntervalSince(startDate)
|
||||
if let url = await VoiceRecorder.shared.stopRecording(),
|
||||
isValidRecording(at: url, duration: finalDuration) {
|
||||
completion(url)
|
||||
} else {
|
||||
guard state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finish(completion: nil)
|
||||
}
|
||||
|
||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
fileSize.intValue > 0,
|
||||
duration >= VoiceRecorder.minRecordingDuration {
|
||||
return true
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@Binding var expandedMessageIDs: Set<String>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -67,12 +66,14 @@ struct TextMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14, iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var ids: Set<String> = []
|
||||
let keychain = PreviewKeychainManager()
|
||||
|
||||
Group {
|
||||
List {
|
||||
TextMessageView(message: .preview)
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
@@ -80,7 +81,7 @@ struct TextMessageView: View {
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
TextMessageView(message: .preview)
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
|
||||
+1009
-97
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct FingerprintView: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// ImagePickerView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Camera or Photo Library
|
||||
struct ImagePickerView: UIViewControllerRepresentable {
|
||||
let sourceType: UIImagePickerController.SourceType
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = sourceType
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsEditing = false
|
||||
|
||||
// Use standard full screen - iOS handles safe areas automatically
|
||||
picker.modalPresentationStyle = .fullScreen
|
||||
|
||||
// Force dark mode to make safe area bars black instead of white
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(completion: completion)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
init(completion: @escaping (UIImage?) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
let image = info[.originalImage] as? UIImage
|
||||
completion(image)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var isPresented = true
|
||||
@Previewable @State var selectedImage: UIImage?
|
||||
VStack {
|
||||
if let selectedImage {
|
||||
Image(uiImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ImagePickerView(sourceType: .photoLibrary) { image in
|
||||
selectedImage = image
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,147 +0,0 @@
|
||||
//
|
||||
// ImagePreviewView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
struct ImagePreviewView: View {
|
||||
let url: URL
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
#if os(iOS)
|
||||
@State private var showExporter = false
|
||||
@State private var platformImage: UIImage?
|
||||
#else
|
||||
@State private var platformImage: NSImage?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
if let image = platformImage {
|
||||
#if os(iOS)
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding()
|
||||
#else
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding()
|
||||
#endif
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Text("close", comment: "Button to dismiss fullscreen media viewer")
|
||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: saveCopy) {
|
||||
Text("save", comment: "Button to save media to device")
|
||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom], 24)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadImage)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showExporter) {
|
||||
FileExportWrapper(url: url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
#else
|
||||
guard let image = NSImage(contentsOf: url) else { return }
|
||||
#endif
|
||||
DispatchQueue.main.async {
|
||||
self.platformImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCopy() {
|
||||
#if os(iOS)
|
||||
showExporter = true
|
||||
#else
|
||||
Task { @MainActor in
|
||||
let panel = NSSavePanel()
|
||||
panel.canCreateDirectories = true
|
||||
panel.nameFieldStringValue = url.lastPathComponent
|
||||
panel.prompt = "save"
|
||||
if panel.runModal() == .OK, let destination = panel.url {
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private struct FileExportWrapper: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||
controller.shouldShowFileExtensions = true
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
|
||||
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
|
||||
#if os(iOS)
|
||||
let image = UIImage(named: "dummy")
|
||||
let data = image?.jpegData(compressionQuality: 0.8)
|
||||
let _ = try? data?.write(to: tempURL)
|
||||
#elseif os(macOS)
|
||||
let image = NSImage(named: "dummy")
|
||||
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
|
||||
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
|
||||
let _ = try? jpegData?.write(to: tempURL)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
ImagePreviewView(url: tempURL)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//
|
||||
// MacImagePickerView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacImagePickerView: View {
|
||||
let completion: (URL?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Choose an image")
|
||||
.font(.headline)
|
||||
|
||||
Button("Select Image") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||
panel.message = "Choose an image to send"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
completion(panel.url)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Cancel") {
|
||||
completion(nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(minWidth: 300, minHeight: 150)
|
||||
}
|
||||
}
|
||||
|
||||
@available(OSX 14, *)
|
||||
#Preview {
|
||||
@Previewable @State var isPresented = true
|
||||
@Previewable @State var selectedImage: NSImage?
|
||||
|
||||
VStack {
|
||||
if let selectedImage {
|
||||
Image(nsImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
MacImagePickerView { url in
|
||||
selectedImage = url.map(NSImage.init)
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -125,7 +125,7 @@ struct LocationChannelsSheet: View {
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -246,7 +246,9 @@ struct LocationChannelsSheet: View {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: SystemSettings.location.open) {
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
@@ -302,7 +304,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash
|
||||
.trimmed
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
@@ -449,7 +451,7 @@ struct LocationChannelsSheet: View {
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
|
||||
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
|
||||
let prefix = String(s[..<idx]).trimmed
|
||||
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
|
||||
let suffix = String(s[idx...])
|
||||
return (prefix, suffix)
|
||||
}
|
||||
@@ -620,3 +622,18 @@ extension LocationChannelsSheet {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open Settings helper
|
||||
private func openSystemLocationSettings() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#else
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -12,15 +12,11 @@ struct LocationNotesView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
|
||||
init(
|
||||
geohash: String,
|
||||
onNotesCountChanged: ((Int) -> Void)? = nil,
|
||||
manager: LocationNotesManager? = nil
|
||||
) {
|
||||
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
|
||||
let gh = geohash.lowercased()
|
||||
self.geohash = gh
|
||||
self.onNotesCountChanged = onNotesCountChanged
|
||||
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
@@ -264,13 +260,14 @@ struct LocationNotesView: View {
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
manager.send(content: content, nickname: viewModel.nickname)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private var sendButtonEnabled: Bool {
|
||||
!draft.trimmed.isEmpty && manager.state != .noRelays
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Formatting
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// MediaMessageView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 30/03/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct MediaMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
let message: BitchatMessage
|
||||
let media: BitchatMessage.Media
|
||||
|
||||
@Binding var imagePreviewURL: URL?
|
||||
|
||||
var body: some View {
|
||||
let state = mediaSendState(for: message)
|
||||
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
|
||||
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
switch media {
|
||||
case .voice(let url):
|
||||
VoiceNoteView(
|
||||
url: url,
|
||||
isSending: state.isSending,
|
||||
sendProgress: state.progress,
|
||||
onCancel: cancelAction
|
||||
)
|
||||
case .image(let url):
|
||||
BlockRevealImageView(
|
||||
url: url,
|
||||
revealProgress: state.progress,
|
||||
isSending: state.isSending,
|
||||
onCancel: cancelAction,
|
||||
initiallyBlurred: !isFromMe,
|
||||
onOpen: {
|
||||
if !state.isSending {
|
||||
imagePreviewURL = url
|
||||
}
|
||||
},
|
||||
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||
)
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
var isSending = false
|
||||
var progress: Double?
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .sending:
|
||||
isSending = true
|
||||
progress = 0
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
if total > 0 {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .read, .delivered, .failed:
|
||||
break
|
||||
}
|
||||
}
|
||||
let canCancel = isSending && message.sender == viewModel.nickname
|
||||
let clamped = progress.map { max(0, min(1, $0)) }
|
||||
return (isSending, isSending ? clamped : nil, canCancel)
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,24 @@ struct VoiceNoteView: View {
|
||||
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let duration = playback.duration
|
||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||
let minutes = Int(duration) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var currentText: String {
|
||||
let current = playback.currentTime
|
||||
guard current.isFinite, current > 0 else { return "00:00" }
|
||||
let minutes = Int(current) / 60
|
||||
let seconds = Int(current) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var playbackLabel: String {
|
||||
guard playback.duration.isFinite else { return "--:--" }
|
||||
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
|
||||
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
||||
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct MeshPeerList: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
//
|
||||
// MessageListView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 30/03/2026.
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
private struct MessageDisplayItem: Identifiable {
|
||||
let id: String
|
||||
let message: BitchatMessage
|
||||
}
|
||||
|
||||
struct MessageListView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let privatePeer: PeerID?
|
||||
@Binding var isAtBottom: Bool
|
||||
@Binding var messageText: String
|
||||
@Binding var selectedMessageSender: String?
|
||||
@Binding var selectedMessageSenderID: PeerID?
|
||||
@Binding var imagePreviewURL: URL?
|
||||
@Binding var windowCountPublic: Int
|
||||
@Binding var windowCountPrivate: [PeerID: Int]
|
||||
@Binding var showSidebar: Bool
|
||||
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
@State private var showMessageActions = false
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
|
||||
var body: some View {
|
||||
let currentWindowCount: Int = {
|
||||
if let peer = privatePeer {
|
||||
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||
}
|
||||
return windowCountPublic
|
||||
}()
|
||||
|
||||
let messages = viewModel.getMessages(for: privatePeer)
|
||||
let windowedMessages = Array(messages.suffix(currentWindowCount))
|
||||
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer {
|
||||
"dm:\(peer)"
|
||||
} else {
|
||||
locationManager.selectedChannel.contextKey
|
||||
}
|
||||
}()
|
||||
|
||||
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
|
||||
guard !message.content.trimmed.isEmpty else { return nil }
|
||||
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
|
||||
}
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(messageItems) { item in
|
||||
let message = item.message
|
||||
messageRow(for: message)
|
||||
.onAppear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = true
|
||||
}
|
||||
if message.id == windowedMessages.first?.id,
|
||||
messages.count > windowedMessages.count {
|
||||
expandWindow(
|
||||
ifNeededFor: message,
|
||||
allMessages: messages,
|
||||
privatePeer: privatePeer,
|
||||
proxy: proxy
|
||||
)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = false
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if message.sender != "system" {
|
||||
messageText = "@\(message.sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button("content.message.copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
#else
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(message.content, forType: .string)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onTapGesture(count: 3) {
|
||||
viewModel.sendMessage("/clear")
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
.onChange(of: privatePeer) { _ in
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) { _ in
|
||||
onMessagesChange(proxy: proxy)
|
||||
}
|
||||
.onChange(of: viewModel.privateChats) { _ in
|
||||
onPrivateChatsChange(proxy: proxy)
|
||||
}
|
||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||
}
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
isPresented: $showMessageActions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
if let sender = selectedMessageSender {
|
||||
// Pre-fill the input with an @mention and focus the field
|
||||
messageText = "@\(sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
if peerID.isGeoChat {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
}
|
||||
} else {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.hug") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.slap") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
let sender = selectedMessageSender {
|
||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||
} else if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/block \(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
if let peerID = privatePeer {
|
||||
// Try multiple times to ensure read receipts are sent
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
// Intercept custom cashu: links created in attributed text
|
||||
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
return .handled
|
||||
#else
|
||||
// On non-iOS platforms, let the system handle or ignore
|
||||
return .systemAction
|
||||
#endif
|
||||
}
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extension MessageListView {
|
||||
@ViewBuilder
|
||||
func messageRow(for message: BitchatMessage) -> some View {
|
||||
Group {
|
||||
if message.sender == "system" {
|
||||
systemMessageRow(message)
|
||||
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
|
||||
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
|
||||
} else {
|
||||
TextMessageView(message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func systemMessageRow(_ message: BitchatMessage) -> some View {
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
proxy: ScrollViewProxy) {
|
||||
let step = TransportConfig.uiWindowStepCount
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer {
|
||||
"dm:\(peer)"
|
||||
} else {
|
||||
locationManager.selectedChannel.contextKey
|
||||
}
|
||||
}()
|
||||
let preserveID = "\(contextKey)|\(message.id)"
|
||||
|
||||
if let peer = privatePeer {
|
||||
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||
let newCount = min(allMessages.count, current + step)
|
||||
guard newCount != current else { return }
|
||||
windowCountPrivate[peer] = newCount
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(preserveID, anchor: .top)
|
||||
}
|
||||
} else {
|
||||
let current = windowCountPublic
|
||||
let newCount = min(allMessages.count, current + step)
|
||||
guard newCount != current else { return }
|
||||
windowCountPublic = newCount
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(preserveID, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
guard url.scheme == "bitchat" else { return }
|
||||
switch url.host {
|
||||
case "user":
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||
selectedMessageSenderID = peerID
|
||||
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||
selectedMessageSender = name
|
||||
} else {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
|
||||
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
||||
selectedMessageSender = nil
|
||||
selectedMessageSenderID = nil
|
||||
} else {
|
||||
showMessageActions = true
|
||||
}
|
||||
|
||||
case "geohash":
|
||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||
|
||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(channel))
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom(on proxy: ScrollViewProxy) {
|
||||
isAtBottom = true
|
||||
if let targetPeerID {
|
||||
proxy.scrollTo(targetPeerID, anchor: .bottom)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
if let secondTarget = self.targetPeerID {
|
||||
proxy.scrollTo(secondTarget, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetPeerID: String? {
|
||||
if let peer = privatePeer,
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
if let last = viewModel.messages.suffix(300).last?.id {
|
||||
return "\(locationManager.selectedChannel.contextKey)|\(last)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
|
||||
|
||||
// If the newest message is from me, always scroll to bottom
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
return
|
||||
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom = true
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = locationManager.selectedChannel.contextKey
|
||||
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle scroll animations to prevent excessive UI updates
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
// Immediate scroll if enough time has passed
|
||||
scrollIfNeeded(date: now)
|
||||
} else {
|
||||
// Schedule a delayed scroll
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
scrollIfNeeded(date: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
||||
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
|
||||
return
|
||||
}
|
||||
|
||||
// If the newest private message is from me, always scroll
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
return
|
||||
} else {
|
||||
isAtBottom = true
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Same throttling for private chats
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
scrollIfNeeded(date: now)
|
||||
} else {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
scrollIfNeeded(date: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
|
||||
// When switching to a new geohash channel, scroll to the bottom
|
||||
guard privatePeer == nil else { return }
|
||||
switch channel {
|
||||
case .mesh:
|
||||
break
|
||||
case .location(let ch):
|
||||
// Reset window size
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey = "geo:\(ch.geohash)"
|
||||
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChannelID {
|
||||
var contextKey: String {
|
||||
switch self {
|
||||
case .mesh: "mesh"
|
||||
case .location(let ch): "geo:\(ch.geohash)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// MessageListView()
|
||||
//}
|
||||
@@ -6,7 +6,6 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "dummy.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -6,7 +6,6 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<string>group.chat.bitchat</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<string>group.chat.bitchat</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
|
||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||
final class ShareViewController: UIViewController {
|
||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||
private static let groupID = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? "group.chat.bitchat"
|
||||
private static let groupID = "group.chat.bitchat"
|
||||
|
||||
private enum Strings {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<string>group.chat.bitchat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -8,7 +8,6 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceCoreTests {
|
||||
@@ -24,18 +23,10 @@ struct BLEServiceCoreTests {
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
let receivedFirst = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(receivedFirst)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!receivedDuplicate)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
|
||||
let messages = delegate.publicMessagesSnapshot()
|
||||
#expect(messages.count == 1)
|
||||
@@ -58,52 +49,13 @@ struct BLEServiceCoreTests {
|
||||
#expect(!didReceive)
|
||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announceSenderMismatch_isRejected() async throws {
|
||||
let ble = makeService()
|
||||
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Spoof",
|
||||
noisePublicKey: signer.getStaticPublicKeyData(),
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
||||
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
|
||||
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
|
||||
let wrongPeerID = PeerID(str: wrongBare)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
|
||||
|
||||
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
|
||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
return BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
}
|
||||
|
||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import Testing
|
||||
import CoreBluetooth
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEServiceTests {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BitchatPeer Tests")
|
||||
struct BitchatPeerTests {
|
||||
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
|
||||
|
||||
@Test("Connection state prioritizes bluetooth, mesh, nostr, then offline")
|
||||
func connectionStatePriorityIsCorrect() {
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)
|
||||
|
||||
let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true)
|
||||
let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true)
|
||||
var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
nostr.favoriteStatus = mutual
|
||||
let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
|
||||
#expect(bluetooth.connectionState == .bluetoothConnected)
|
||||
#expect(mesh.connectionState == .meshReachable)
|
||||
#expect(nostr.connectionState == .nostrAvailable)
|
||||
#expect(offline.connectionState == .offline)
|
||||
}
|
||||
|
||||
@Test("Display name falls back to peer prefix and offline icon reflects inbound favorite")
|
||||
func displayNameAndOfflineIconUseDerivedState() {
|
||||
let peerID = PeerID(str: "fedcba9876543210")
|
||||
let noiseKey = Data((32..<64).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)
|
||||
|
||||
#expect(peer.displayName == String(peerID.id.prefix(8)))
|
||||
#expect(peer.statusIcon == "🌙")
|
||||
}
|
||||
|
||||
@Test("Mutual offline peers show Nostr icon")
|
||||
func mutualFavoriteOfflinePeerShowsNostrIcon() {
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let noiseKey = Data((64..<96).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)
|
||||
|
||||
#expect(peer.statusIcon == "🌐")
|
||||
#expect(peer.isFavorite)
|
||||
#expect(peer.isMutualFavorite)
|
||||
#expect(peer.theyFavoritedUs)
|
||||
}
|
||||
|
||||
@Test("Equality is based only on peer ID")
|
||||
func equalityUsesPeerIDOnly() {
|
||||
let peerID = PeerID(str: "8899aabbccddeeff")
|
||||
let first = BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data(repeating: 1, count: 32),
|
||||
nickname: "First",
|
||||
isConnected: false,
|
||||
isReachable: false
|
||||
)
|
||||
let second = BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data(repeating: 2, count: 32),
|
||||
nickname: "Second",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {
|
||||
FavoriteRelationship(
|
||||
peerNoisePublicKey: Data(repeating: 7, count: 32),
|
||||
peerNostrPublicKey: "npub1example",
|
||||
peerNickname: "Peer",
|
||||
isFavorite: isFavorite,
|
||||
theyFavoritedUs: theyFavoritedUs,
|
||||
favoritedAt: Date(timeIntervalSince1970: 1),
|
||||
lastUpdated: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user