mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 08:05:21 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca420567b3 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
b81ae0b4c0 | ||
|
|
b6d42261d0 |
@@ -7,6 +7,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-relay-data:
|
update-relay-data:
|
||||||
@@ -17,24 +18,54 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Configure git
|
||||||
id: git-check
|
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
git config user.email "action@github.com"
|
||||||
|
git config user.name "GitHub Action"
|
||||||
|
|
||||||
- name: Commit and push changes
|
- name: Create update branch if changes
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
id: create_branch
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
# exit early if no changes
|
||||||
git config --local user.name "GitHub Action"
|
if git diff --quiet --relays/online_relays_gps.csv; then
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# branch name with timestamp
|
||||||
|
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
|
||||||
git add relays/online_relays_gps.csv
|
git add relays/online_relays_gps.csv
|
||||||
git commit -m "Automated update of relay data - $(date -u)"
|
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
|
||||||
git push
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
env:
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
- name: Push branch
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
if: steps.create_branch.outputs.changed == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: Automated update of relay data
|
||||||
|
branch: ${{ steps.create_branch.outputs.branch }}
|
||||||
|
base: main
|
||||||
|
title: Automated update of relay data
|
||||||
|
body: |
|
||||||
|
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
|
||||||
|
labels: automated, georelays
|
||||||
|
|
||||||
|
- name: No changes
|
||||||
|
if: steps.create_branch.outputs.changed != 'true'
|
||||||
|
run: echo "No changes to relays/online_relays_gps.csv"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.4
|
MARKETING_VERSION = 1.5.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||||
|
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||||
|
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||||
|
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
|
|
||||||
# Backup original files
|
# Backup original files
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum ImageUtilsError: Error {
|
||||||
|
case invalidImage
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImageUtils {
|
||||||
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
|
private static let targetImageBytes: Int = 60_000
|
||||||
|
|
||||||
|
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
// Allow up to 10MB source images (will be scaled down)
|
||||||
|
guard fileSize <= 10 * 1024 * 1024 else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
#if os(iOS)
|
||||||
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
|
#else
|
||||||
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
|
return try processImage(image, maxDimension: maxDimension)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
// Scale the image first
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
|
||||||
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
|
guard let cgImage = scaled.cgImage else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress to target size
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
|
||||||
|
// Draw into a new context to get a clean CGImage without metadata
|
||||||
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return rendered ?? image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
|
return try autoreleasepool {
|
||||||
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
let width = inputCG.width
|
||||||
|
let height = inputCG.height
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
|
quality -= 0.1
|
||||||
|
autoreleasepool {
|
||||||
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
|
jpegData = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||||
|
let size = image.size
|
||||||
|
let maxSide = max(size.width, size.height)
|
||||||
|
guard maxSide > maxDimension else { return image }
|
||||||
|
let scale = maxDimension / maxSide
|
||||||
|
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
let scaledImage = NSImage(size: newSize)
|
||||||
|
scaledImage.lockFocus()
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||||
|
from: NSRect(origin: .zero, size: size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
scaledImage.unlockFocus()
|
||||||
|
return scaledImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private static func makeOutputURL() throws -> URL {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
||||||
|
|
||||||
|
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return directory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func applicationFilesDirectory() throws -> URL {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
return base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||||
|
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||||
|
@Published private(set) var isPlaying: Bool = false
|
||||||
|
@Published private(set) var currentTime: TimeInterval = 0
|
||||||
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
|
private var player: AVAudioPlayer?
|
||||||
|
private var timer: Timer?
|
||||||
|
private var url: URL
|
||||||
|
|
||||||
|
init(url: URL) {
|
||||||
|
self.url = url
|
||||||
|
super.init()
|
||||||
|
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDuration() {
|
||||||
|
guard duration == 0 else { return }
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||||
|
let loadedDuration = player.duration
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self, self.duration == 0 else { return }
|
||||||
|
self.duration = loadedDuration
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceURL(_ url: URL) {
|
||||||
|
guard url != self.url else { return }
|
||||||
|
stop()
|
||||||
|
self.url = url
|
||||||
|
player = nil
|
||||||
|
duration = 0
|
||||||
|
// Duration will be loaded on demand when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayback() {
|
||||||
|
isPlaying ? pause() : play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
guard ensurePlayerReady() else { return }
|
||||||
|
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||||
|
player?.play()
|
||||||
|
startTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player?.pause()
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
player?.stop()
|
||||||
|
player?.currentTime = 0
|
||||||
|
stopTimer()
|
||||||
|
updateProgress()
|
||||||
|
isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to fraction: Double) {
|
||||||
|
guard ensurePlayerReady() else { return }
|
||||||
|
let clamped = max(0, min(1, fraction))
|
||||||
|
if let player = player {
|
||||||
|
player.currentTime = clamped * player.duration
|
||||||
|
if isPlaying {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVAudioPlayerDelegate
|
||||||
|
|
||||||
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||||
|
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopTimer()
|
||||||
|
self.updateProgress()
|
||||||
|
self.isPlaying = false
|
||||||
|
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func preparePlayer(for url: URL) {
|
||||||
|
// Prepare player synchronously (only called when playback is requested)
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
|
player.delegate = self
|
||||||
|
player.prepareToPlay()
|
||||||
|
self.player = player
|
||||||
|
duration = player.duration
|
||||||
|
currentTime = player.currentTime
|
||||||
|
progress = duration > 0 ? currentTime / duration : 0
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
|
player = nil
|
||||||
|
duration = 0
|
||||||
|
currentTime = 0
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensurePlayerReady() -> Bool {
|
||||||
|
if player == nil {
|
||||||
|
preparePlayer(for: url)
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return player != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
if timer != nil { return }
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
|
self?.updateProgress()
|
||||||
|
}
|
||||||
|
if let timer = timer {
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress() {
|
||||||
|
guard let player = player else {
|
||||||
|
currentTime = 0
|
||||||
|
duration = 0
|
||||||
|
progress = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentTime = player.currentTime
|
||||||
|
duration = player.duration
|
||||||
|
progress = duration > 0 ? currentTime / duration : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures only one voice note plays at a time.
|
||||||
|
final class VoiceNotePlaybackCoordinator {
|
||||||
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
|
private weak var activeController: VoiceNotePlaybackController?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func activate(_ controller: VoiceNotePlaybackController) {
|
||||||
|
if activeController === controller {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeController?.pause()
|
||||||
|
activeController = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||||
|
if activeController === controller {
|
||||||
|
activeController = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||||
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
|
enum RecorderError: Error {
|
||||||
|
case microphoneAccessDenied
|
||||||
|
case recorderInitializationFailed
|
||||||
|
case recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
|
private var recorder: AVAudioRecorder?
|
||||||
|
private var currentURL: URL?
|
||||||
|
private var stopWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permissions
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
func startRecording() throws -> URL {
|
||||||
|
try queue.sync {
|
||||||
|
if recorder?.isRecording == true {
|
||||||
|
throw RecorderError.recordingInProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
|
)
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 20_000
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.delegate = self
|
||||||
|
audioRecorder.isMeteringEnabled = true
|
||||||
|
audioRecorder.prepareToRecord()
|
||||||
|
audioRecorder.record()
|
||||||
|
|
||||||
|
recorder = audioRecorder
|
||||||
|
currentURL = outputURL
|
||||||
|
stopWorkItem?.cancel()
|
||||||
|
stopWorkItem = nil
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||||
|
completion(self?.currentURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
recorder.stop()
|
||||||
|
self.cleanupSession()
|
||||||
|
let url = self.currentURL
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = url
|
||||||
|
completion(url)
|
||||||
|
}
|
||||||
|
self.stopWorkItem = item
|
||||||
|
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRecording() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.stopWorkItem?.cancel()
|
||||||
|
self.stopWorkItem = nil
|
||||||
|
if let recorder = self.recorder, recorder.isRecording {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
self.cleanupSession()
|
||||||
|
if let url = self.currentURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
self.recorder = nil
|
||||||
|
self.currentURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metering
|
||||||
|
|
||||||
|
func currentAveragePower() -> Float {
|
||||||
|
queue.sync {
|
||||||
|
recorder?.updateMeters()
|
||||||
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func makeOutputURL() throws -> URL {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||||
|
|
||||||
|
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return baseDirectory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applicationFilesDirectory() throws -> URL {
|
||||||
|
#if os(iOS)
|
||||||
|
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
#else
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
return base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupSession() {
|
||||||
|
#if os(iOS)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||||
|
final class WaveformCache {
|
||||||
|
static let shared = WaveformCache()
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||||
|
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||||
|
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func cachedWaveform(for url: URL) -> [Float]? {
|
||||||
|
queue.sync {
|
||||||
|
guard let entry = cache[url] else { return nil }
|
||||||
|
return entry.waveform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Check cache (read-only, no update needed on cache hit for performance)
|
||||||
|
if let entry = self.cache[url] {
|
||||||
|
DispatchQueue.main.async { completion(entry.waveform) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||||
|
DispatchQueue.main.async { completion([]) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Evict oldest entry if cache is full
|
||||||
|
if self.cache.count >= self.maxCacheSize {
|
||||||
|
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||||
|
self.cache.removeValue(forKey: oldest.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache[url] = (computed, Date())
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { completion(computed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purge(url: URL) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeAll() {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
|
guard bins > 0 else { return nil }
|
||||||
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
return autoreleasepool {
|
||||||
|
do {
|
||||||
|
let audioFile = try AVAudioFile(forReading: url)
|
||||||
|
let length = Int(audioFile.length)
|
||||||
|
guard length > 0 else { return nil }
|
||||||
|
|
||||||
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||||
|
guard let channelData = buffer.floatChannelData else { return nil }
|
||||||
|
|
||||||
|
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
let samplesPerBin = max(1, frameLength / bins)
|
||||||
|
|
||||||
|
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||||
|
for bin in 0..<bins {
|
||||||
|
let start = bin * samplesPerBin
|
||||||
|
let end = min(frameLength, start + samplesPerBin)
|
||||||
|
if start >= end { break }
|
||||||
|
|
||||||
|
var sum: Float = 0
|
||||||
|
var sampleCount = 0
|
||||||
|
for frame in start..<end {
|
||||||
|
var sampleValue: Float = 0
|
||||||
|
for channel in 0..<channelCount {
|
||||||
|
sampleValue += fabsf(channelData[channel][frame])
|
||||||
|
}
|
||||||
|
sum += sampleValue / Float(channelCount)
|
||||||
|
sampleCount += 1
|
||||||
|
}
|
||||||
|
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||||
|
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||||
|
}
|
||||||
|
return magnitudes
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
+24486
-23266
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
|
|||||||
var signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
|
||||||
self.version = 1
|
self.version = version
|
||||||
self.type = type
|
self.type = type
|
||||||
self.senderID = senderID
|
self.senderID = senderID
|
||||||
self.recipientID = recipientID
|
self.recipientID = recipientID
|
||||||
@@ -80,7 +80,8 @@ struct BitchatPacket: Codable {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Remove signature for signing
|
signature: nil, // Remove signature for signing
|
||||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
version: version
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import Foundation
|
|||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
let receiptID: String
|
let receiptID: String
|
||||||
var readerID: String // Who read it
|
var readerID: PeerID // Who read it
|
||||||
let readerNickname: String
|
let readerNickname: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = UUID().uuidString
|
self.receiptID = UUID().uuidString
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For binary decoding
|
// For binary decoding
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||||
self.originalMessageID = originalMessageID
|
self.originalMessageID = originalMessageID
|
||||||
self.receiptID = receiptID
|
self.receiptID = receiptID
|
||||||
self.readerID = readerID
|
self.readerID = readerID
|
||||||
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
|
|||||||
data.appendUUID(receiptID)
|
data.appendUUID(receiptID)
|
||||||
// ReaderID as 8-byte hex string
|
// ReaderID as 8-byte hex string
|
||||||
var readerData = Data()
|
var readerData = Data()
|
||||||
var tempID = readerID
|
var tempID = readerID.id
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
|
|||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
let readerID = PeerID(hexData: readerIDData)
|
||||||
guard PeerID(str: readerID).isValid else { return nil }
|
guard readerID.isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
|
|||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
|
|
||||||
default:
|
case .e, .s:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
import Tor
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -12,19 +17,32 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||||
|
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private var retryTask: Task<Void, Never>?
|
||||||
|
private var retryAttempt: Int = 0
|
||||||
|
private var isFetching: Bool = false
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load cached or bundled data synchronously
|
entries = loadLocalEntries()
|
||||||
self.entries = self.loadLocalEntries()
|
registerObservers()
|
||||||
// Fire-and-forget remote refresh if stale
|
startRefreshTimer()
|
||||||
prefetchIfNeeded()
|
prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
retryTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
let center = Geohash.decodeCenter(geohash)
|
let center = Geohash.decodeCenter(geohash)
|
||||||
@@ -33,50 +51,146 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
let sorted = entries
|
|
||||||
|
if entries.count <= count {
|
||||||
|
return entries
|
||||||
.sorted { a, b in
|
.sorted { a, b in
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
}
|
}
|
||||||
.prefix(count)
|
.map { "wss://\($0.host)" }
|
||||||
return sorted.map { "wss://\($0.host)" }
|
}
|
||||||
|
|
||||||
|
var best: [(entry: Entry, distance: Double)] = []
|
||||||
|
best.reserveCapacity(count)
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
||||||
|
if best.count < count {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
||||||
|
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
||||||
|
best.insert((entry, distance), at: idx)
|
||||||
|
best.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best.map { "wss://\($0.entry.host)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded() {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
|
||||||
|
if !force {
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
|
} else if last != .distantPast,
|
||||||
|
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||||
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRetry()
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
guard !isFetching else { return }
|
||||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
isFetching = true
|
||||||
Task.detached {
|
|
||||||
|
let request = URLRequest(
|
||||||
|
url: remoteURL,
|
||||||
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
|
timeoutInterval: 15
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
if !ready {
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
await self.handleFetchFailure(.torNotReady)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
|
||||||
guard let self = self else { return }
|
do {
|
||||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
await self.handleFetchFailure(.invalidData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
if !parsed.isEmpty {
|
guard !parsed.isEmpty else {
|
||||||
Task { @MainActor in
|
await self.handleFetchFailure(.invalidData)
|
||||||
self.entries = parsed
|
|
||||||
self.persistCache(text)
|
|
||||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||||
|
} catch {
|
||||||
|
await self.handleFetchFailure(.network(error))
|
||||||
}
|
}
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
|
||||||
}
|
}
|
||||||
task.resume()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum FetchFailure {
|
||||||
|
case torNotReady
|
||||||
|
case invalidData
|
||||||
|
case network(Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
|
entries = parsed
|
||||||
|
persistCache(csv)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||||
|
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
|
isFetching = false
|
||||||
|
retryAttempt = 0
|
||||||
|
cancelRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||||
|
switch reason {
|
||||||
|
case .torNotReady:
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||||
|
case .invalidData:
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||||
|
case .network(let error):
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||||
|
}
|
||||||
|
isFetching = false
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func scheduleRetry() {
|
||||||
|
retryAttempt = min(retryAttempt + 1, 10)
|
||||||
|
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||||
|
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||||
|
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||||
|
let calculated = base * multiplier
|
||||||
|
let delay = min(maxDelay, max(base, calculated))
|
||||||
|
|
||||||
|
cancelRetry()
|
||||||
|
retryTask = Task { [weak self] in
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
await MainActor.run {
|
||||||
|
self?.prefetchIfNeeded(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func cancelRetry() {
|
||||||
|
retryTask?.cancel()
|
||||||
|
retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
@@ -91,30 +205,35 @@ final class GeoRelayDirectory {
|
|||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = self.cacheURL(),
|
if let cache = cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = [
|
let bundleCandidates = [
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
if let data = try? Data(contentsOf: url),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -122,7 +241,6 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
// Skip header if present
|
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.isEmpty { continue }
|
if line.isEmpty { continue }
|
||||||
@@ -143,11 +261,67 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
private func cacheURL() -> URL? {
|
private func cacheURL() -> URL? {
|
||||||
do {
|
do {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
} catch { return nil }
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observers & Timers
|
||||||
|
private func registerObservers() {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
|
||||||
|
let torReady = center.addObserver(
|
||||||
|
forName: .TorDidBecomeReady,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.prefetchIfNeeded(force: true)
|
||||||
|
}
|
||||||
|
observers.append(torReady)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let didBecomeActive = center.addObserver(
|
||||||
|
forName: UIApplication.didBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
observers.append(didBecomeActive)
|
||||||
|
#elseif os(macOS)
|
||||||
|
let didBecomeActive = center.addObserver(
|
||||||
|
forName: NSApplication.didBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
observers.append(didBecomeActive)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRefreshTimer() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||||
|
guard interval > 0 else { return }
|
||||||
|
|
||||||
|
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshTimer = timer
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientIDHex),
|
recipientID: Data(hexString: recipientID.id),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerID(publicKey: maybeData).id
|
return PeerID(publicKey: maybeData)
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ final class NostrIdentityBridge {
|
|||||||
private let deviceSeedKey = "nostr-device-seed"
|
private let deviceSeedKey = "nostr-device-seed"
|
||||||
// In-memory cache to avoid transient keychain access issues
|
// In-memory cache to avoid transient keychain access issues
|
||||||
private var deviceSeedCache: Data?
|
private var deviceSeedCache: Data?
|
||||||
|
// Cache derived identities to avoid repeated crypto during view rendering
|
||||||
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||||
|
private let cacheLock = NSLock()
|
||||||
|
|
||||||
private let keychain: KeychainHelperProtocol
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
@@ -106,6 +109,14 @@ final class NostrIdentityBridge {
|
|||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||||
|
cacheLock.lock()
|
||||||
|
if let cached = derivedIdentityCache[geohash] {
|
||||||
|
cacheLock.unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheLock.unlock()
|
||||||
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
let seed = getOrCreateDeviceSeed()
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
guard let msg = geohash.data(using: .utf8) else {
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||||
@@ -125,11 +136,22 @@ final class NostrIdentityBridge {
|
|||||||
for i in 0..<10 {
|
for i in 0..<10 {
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
let keyData = candidateKey(iteration: UInt32(i))
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||||
|
// Cache the result
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache[geohash] = identity
|
||||||
|
cacheLock.unlock()
|
||||||
return identity
|
return identity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// As a final fallback, hash the seed+msg and try again
|
// As a final fallback, hash the seed+msg and try again
|
||||||
let fallback = (seed + msg).sha256Hash()
|
let fallback = (seed + msg).sha256Hash()
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache[geohash] = identity
|
||||||
|
cacheLock.unlock()
|
||||||
|
|
||||||
|
return identity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -906,6 +906,16 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||||
|
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [1]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["g": geohashes]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ extension Data {
|
|||||||
return self.map { String(format: "%02x", $0) }.joined()
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sha256Hex() -> String {
|
||||||
|
let digest = SHA256.hash(data: self)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
init?(hexString: String) {
|
init?(hexString: String) {
|
||||||
let len = hexString.count / 2
|
let len = hexString.count / 2
|
||||||
var data = Data(capacity: len)
|
var data = Data(capacity: len)
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
///
|
///
|
||||||
/// ## Wire Format
|
/// ## Wire Format
|
||||||
/// ```
|
/// ```
|
||||||
/// Header (Fixed 13 bytes):
|
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
|
||||||
/// +--------+------+-----+-----------+-------+----------------+
|
/// +--------+------+-----+-----------+-------+------------------+
|
||||||
///
|
///
|
||||||
/// Variable sections:
|
/// Variable sections:
|
||||||
/// +----------+-------------+---------+------------+
|
/// +----------+-------------+---------+------------+
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
/// ## Flag Bits
|
/// ## Flag Bits
|
||||||
/// - Bit 0: Has recipient ID (directed message)
|
/// - Bit 0: Has recipient ID (directed message)
|
||||||
/// - Bit 1: Has signature (authenticated message)
|
/// - Bit 1: Has signature (authenticated message)
|
||||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
/// - Bit 2: Is compressed (zlib compression applied)
|
||||||
/// - Bits 3-7: Reserved for future use
|
/// - Bits 3-7: Reserved for future use
|
||||||
///
|
///
|
||||||
/// ## Size Constraints
|
/// ## Size Constraints
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
func trimmingNullBytes() -> Data {
|
func trimmingNullBytes() -> Data {
|
||||||
@@ -105,11 +106,33 @@ extension Data {
|
|||||||
/// their binary wire format representation.
|
/// their binary wire format representation.
|
||||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
struct BinaryProtocol {
|
struct BinaryProtocol {
|
||||||
static let headerSize = 13
|
static let v1HeaderSize = 14
|
||||||
|
static let v2HeaderSize = 16
|
||||||
static let senderIDSize = 8
|
static let senderIDSize = 8
|
||||||
static let recipientIDSize = 8
|
static let recipientIDSize = 8
|
||||||
static let signatureSize = 64
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
// Field offsets within packet header
|
||||||
|
struct Offsets {
|
||||||
|
static let version = 0
|
||||||
|
static let type = 1
|
||||||
|
static let ttl = 2
|
||||||
|
static let timestamp = 3
|
||||||
|
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func headerSize(for version: UInt8) -> Int? {
|
||||||
|
switch version {
|
||||||
|
case 1: return v1HeaderSize
|
||||||
|
case 2: return v2HeaderSize
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lengthFieldSize(for version: UInt8) -> Int {
|
||||||
|
return version == 2 ? 4 : 2
|
||||||
|
}
|
||||||
|
|
||||||
struct Flags {
|
struct Flags {
|
||||||
static let hasRecipient: UInt8 = 0x01
|
static let hasRecipient: UInt8 = 0x01
|
||||||
static let hasSignature: UInt8 = 0x02
|
static let hasSignature: UInt8 = 0x02
|
||||||
@@ -118,70 +141,69 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
var data = Data()
|
let version = packet.version
|
||||||
|
guard version == 1 || version == 2 else { return nil }
|
||||||
|
|
||||||
|
// Try to compress payload when beneficial, keeping original size for later decoding
|
||||||
// Try to compress payload if beneficial
|
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
var originalPayloadSize: UInt16? = nil
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
var originalPayloadSize: Int?
|
||||||
if CompressionUtil.shouldCompress(payload) {
|
if CompressionUtil.shouldCompress(payload) {
|
||||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
// Only compress when we can represent the original length in the outbound frame
|
||||||
// Store original size for decompression (2 bytes after payload)
|
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
|
||||||
originalPayloadSize = UInt16(payload.count)
|
if payload.count <= maxRepresentable,
|
||||||
|
let compressedPayload = CompressionUtil.compress(payload) {
|
||||||
|
originalPayloadSize = payload.count
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
isCompressed = true
|
isCompressed = true
|
||||||
|
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
|
||||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
let payloadDataSize = payload.count + originalSizeFieldBytes
|
||||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
|
||||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
|
||||||
data.reserveCapacity(estimated)
|
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
|
||||||
data.append(packet.version)
|
|
||||||
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
|
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
|
||||||
|
let estimatedPayload = payloadDataSize
|
||||||
|
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
|
||||||
|
|
||||||
|
data.append(version)
|
||||||
data.append(packet.type)
|
data.append(packet.type)
|
||||||
data.append(packet.ttl)
|
data.append(packet.ttl)
|
||||||
|
|
||||||
// Timestamp (8 bytes, big-endian)
|
for shift in stride(from: 56, through: 0, by: -8) {
|
||||||
for i in (0..<8).reversed() {
|
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
|
||||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
var flags: UInt8 = 0
|
var flags: UInt8 = 0
|
||||||
if packet.recipientID != nil {
|
if packet.recipientID != nil { flags |= Flags.hasRecipient }
|
||||||
flags |= Flags.hasRecipient
|
if packet.signature != nil { flags |= Flags.hasSignature }
|
||||||
}
|
if isCompressed { flags |= Flags.isCompressed }
|
||||||
if packet.signature != nil {
|
|
||||||
flags |= Flags.hasSignature
|
|
||||||
}
|
|
||||||
if isCompressed {
|
|
||||||
flags |= Flags.isCompressed
|
|
||||||
}
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
|
|
||||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
if version == 2 {
|
||||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
let length = UInt32(payloadDataSize)
|
||||||
let payloadLength = UInt16(payloadDataSize)
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let length = UInt16(payloadDataSize)
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(payloadLength & 0xFF))
|
|
||||||
|
|
||||||
// SenderID (exactly 8 bytes)
|
|
||||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
data.append(senderBytes)
|
data.append(senderBytes)
|
||||||
if senderBytes.count < senderIDSize {
|
if senderBytes.count < senderIDSize {
|
||||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipientID (if present)
|
|
||||||
if let recipientID = packet.recipientID {
|
if let recipientID = packet.recipientID {
|
||||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||||
data.append(recipientBytes)
|
data.append(recipientBytes)
|
||||||
@@ -190,29 +212,29 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload (with original size prepended if compressed)
|
|
||||||
if isCompressed, let originalSize = originalPayloadSize {
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
// Prepend original size (2 bytes, big-endian)
|
if version == 2 {
|
||||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
let value = UInt32(originalSize)
|
||||||
data.append(UInt8(originalSize & 0xFF))
|
for shift in stride(from: 24, through: 0, by: -8) {
|
||||||
|
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = UInt16(originalSize)
|
||||||
|
data.append(UInt8((value >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(value & 0xFF))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
data.append(payload)
|
data.append(payload)
|
||||||
|
|
||||||
// Signature (if present)
|
|
||||||
if let signature = packet.signature {
|
if let signature = packet.signature {
|
||||||
data.append(signature.prefix(signatureSize))
|
data.append(signature.prefix(signatureSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Apply padding to standard block sizes for traffic analysis resistance
|
|
||||||
if padding {
|
if padding {
|
||||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
return MessagePadding.pad(data, toSize: optimalSize)
|
||||||
return paddedData
|
|
||||||
} else {
|
|
||||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
@@ -227,87 +249,113 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
// Minimum size: header + senderID
|
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
|
||||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
|
||||||
|
|
||||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
guard let base = buf.baseAddress else { return nil }
|
guard let base = buf.baseAddress else { return nil }
|
||||||
var offset = 0
|
var offset = 0
|
||||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
// Read single byte
|
|
||||||
func read8() -> UInt8? {
|
func read8() -> UInt8? {
|
||||||
guard require(1) else { return nil }
|
guard require(1) else { return nil }
|
||||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
offset += 1
|
offset += 1
|
||||||
return v
|
return value
|
||||||
}
|
}
|
||||||
// Read big-endian 16-bit
|
|
||||||
func read16() -> UInt16? {
|
func read16() -> UInt16? {
|
||||||
guard require(2) else { return nil }
|
guard require(2) else { return nil }
|
||||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return v
|
return value
|
||||||
|
}
|
||||||
|
func read32() -> UInt32? {
|
||||||
|
guard require(4) else { return nil }
|
||||||
|
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
|
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
|
||||||
|
offset += 4
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
// Copy N bytes into Data
|
|
||||||
func readData(_ n: Int) -> Data? {
|
func readData(_ n: Int) -> Data? {
|
||||||
guard require(n) else { return nil }
|
guard require(n) else { return nil }
|
||||||
let ptr = base.advanced(by: offset)
|
let ptr = base.advanced(by: offset)
|
||||||
let d = Data(bytes: ptr, count: n)
|
let data = Data(bytes: ptr, count: n)
|
||||||
offset += n
|
offset += n
|
||||||
return d
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version
|
guard let version = read8(), version == 1 || version == 2 else { return nil }
|
||||||
guard let version = read8(), version == 1 else { return nil }
|
let lengthFieldBytes = lengthFieldSize(for: version)
|
||||||
guard let type = read8() else { return nil }
|
guard let headerSize = headerSize(for: version) else { return nil }
|
||||||
guard let ttl = read8() else { return nil }
|
let minimumRequired = headerSize + senderIDSize
|
||||||
|
guard raw.count >= minimumRequired else { return nil }
|
||||||
|
|
||||||
// Timestamp 8 bytes BE
|
guard let type = read8(), let ttl = read8() else { return nil }
|
||||||
guard require(8) else { return nil }
|
|
||||||
var ts: UInt64 = 0
|
var timestamp: UInt64 = 0
|
||||||
for _ in 0..<8 {
|
for _ in 0..<8 {
|
||||||
guard let b = read8() else { return nil }
|
guard let byte = read8() else { return nil }
|
||||||
ts = (ts << 8) | UInt64(b)
|
timestamp = (timestamp << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flags
|
|
||||||
guard let flags = read8() else { return nil }
|
guard let flags = read8() else { return nil }
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
// Payload length
|
let payloadLength: Int
|
||||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
if version == 2 {
|
||||||
|
guard let len = read32() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
} else {
|
||||||
|
guard let len = read16() else { return nil }
|
||||||
|
payloadLength = Int(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard payloadLength >= 0 else { return nil }
|
||||||
|
|
||||||
// SenderID
|
|
||||||
guard let senderID = readData(senderIDSize) else { return nil }
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
|
|
||||||
// Recipient
|
|
||||||
var recipientID: Data? = nil
|
var recipientID: Data? = nil
|
||||||
if hasRecipient {
|
if hasRecipient {
|
||||||
recipientID = readData(recipientIDSize)
|
recipientID = readData(recipientIDSize)
|
||||||
if recipientID == nil { return nil }
|
if recipientID == nil { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload
|
|
||||||
let payload: Data
|
let payload: Data
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
// Need original size (2 bytes)
|
guard payloadLength >= lengthFieldBytes else { return nil }
|
||||||
guard let origSize16 = read16() else { return nil }
|
let originalSize: Int
|
||||||
let originalSize = Int(origSize16)
|
if version == 2 {
|
||||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
guard let rawSize = read32() else { return nil }
|
||||||
let compSize = Int(payloadLen) - 2
|
originalSize = Int(rawSize)
|
||||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
} else {
|
||||||
|
guard let rawSize = read16() else { return nil }
|
||||||
|
originalSize = Int(rawSize)
|
||||||
|
}
|
||||||
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
|
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
|
||||||
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
|
||||||
|
// Validate compression ratio to prevent zip bomb attacks
|
||||||
|
// Primary protection: originalSize capped at 1MB (line 336)
|
||||||
|
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
|
||||||
|
guard compressedSize > 0 else { return nil }
|
||||||
|
let compressionRatio = Double(originalSize) / Double(compressedSize)
|
||||||
|
guard compressionRatio <= 50_000.0 else {
|
||||||
|
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
decompressed.count == originalSize else { return nil }
|
decompressed.count == originalSize else { return nil }
|
||||||
payload = decompressed
|
payload = decompressed
|
||||||
} else {
|
} else {
|
||||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
guard let rawPayload = readData(payloadLength) else { return nil }
|
||||||
payload = p
|
payload = rawPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature
|
|
||||||
var signature: Data? = nil
|
var signature: Data? = nil
|
||||||
if hasSignature {
|
if hasSignature {
|
||||||
signature = readData(signatureSize)
|
signature = readData(signatureSize)
|
||||||
@@ -320,10 +368,11 @@ struct BinaryProtocol {
|
|||||||
type: type,
|
type: type,
|
||||||
senderID: senderID,
|
senderID: senderID,
|
||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
ttl: ttl
|
ttl: ttl,
|
||||||
|
version: version
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// BitchatFilePacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||||
|
struct BitchatFilePacket {
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var mimeType: String?
|
||||||
|
var content: Data
|
||||||
|
|
||||||
|
/// Canonical TLV tags defined by the Android implementation.
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case fileName = 0x01
|
||||||
|
case fileSize = 0x02
|
||||||
|
case mimeType = 0x03
|
||||||
|
case content = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||||
|
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||||
|
func encode() -> Data? {
|
||||||
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
|
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||||
|
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||||
|
guard content.count <= Int(UInt32.max) else { return nil }
|
||||||
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
|
||||||
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
|
var big = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
|
||||||
|
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||||
|
encoded.append(TLVType.fileName.rawValue)
|
||||||
|
appendBE(UInt16(nameData.count), into: &encoded)
|
||||||
|
encoded.append(nameData)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.append(TLVType.fileSize.rawValue)
|
||||||
|
appendBE(UInt16(4), into: &encoded)
|
||||||
|
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||||
|
|
||||||
|
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||||
|
encoded.append(TLVType.mimeType.rawValue)
|
||||||
|
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||||
|
encoded.append(mimeData)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded.append(TLVType.content.rawValue)
|
||||||
|
appendBE(UInt32(content.count), into: &encoded)
|
||||||
|
encoded.append(content)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||||
|
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: UInt64?
|
||||||
|
var mimeType: String?
|
||||||
|
var content = Data()
|
||||||
|
|
||||||
|
while cursor < end {
|
||||||
|
let typeRaw = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
|
||||||
|
guard cursor <= end else { return nil }
|
||||||
|
let tlvType = TLVType(rawValue: typeRaw)
|
||||||
|
|
||||||
|
func readBigEndianLength(bytes: Int) -> Int? {
|
||||||
|
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||||
|
// Use UInt64 to prevent integer overflow during shift operations
|
||||||
|
var result: UInt64 = 0
|
||||||
|
for _ in 0..<bytes {
|
||||||
|
result = (result << 8) | UInt64(data[cursor])
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
}
|
||||||
|
// Safely convert to Int with overflow check
|
||||||
|
guard result <= Int.max else { return nil }
|
||||||
|
return Int(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let length: Int?
|
||||||
|
if tlvType == .content {
|
||||||
|
let snapshot = cursor
|
||||||
|
let canonical = readBigEndianLength(bytes: 4)
|
||||||
|
if let canonical = canonical,
|
||||||
|
canonical <= data.distance(from: cursor, to: end) {
|
||||||
|
length = canonical
|
||||||
|
} else {
|
||||||
|
cursor = snapshot
|
||||||
|
length = readBigEndianLength(bytes: 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
length = readBigEndianLength(bytes: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||||
|
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||||
|
|
||||||
|
let valueStart = cursor
|
||||||
|
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||||
|
let value = data[valueStart..<cursor]
|
||||||
|
|
||||||
|
switch tlvType {
|
||||||
|
case .fileName:
|
||||||
|
fileName = String(data: Data(value), encoding: .utf8)
|
||||||
|
case .fileSize:
|
||||||
|
if tlvLength == 4 || tlvLength == 8 {
|
||||||
|
var size: UInt64 = 0
|
||||||
|
for byte in value {
|
||||||
|
size = (size << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fileSize = size
|
||||||
|
}
|
||||||
|
case .mimeType:
|
||||||
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
let proposedSize = content.count + value.count
|
||||||
|
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content.append(contentsOf: value)
|
||||||
|
case nil:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !content.isEmpty else { return nil }
|
||||||
|
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||||
|
return BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
mimeType: mimeType,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ enum MessageType: UInt8 {
|
|||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -89,6 +90,7 @@ enum MessageType: UInt8 {
|
|||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
|
case .fileTransfer: return "fileTransfer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,4 +119,57 @@ enum Geohash {
|
|||||||
}
|
}
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||||
|
static func neighbors(of geohash: String) -> [String] {
|
||||||
|
guard !geohash.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let precision = geohash.count
|
||||||
|
let bounds = decodeBounds(geohash)
|
||||||
|
let center = decodeCenter(geohash)
|
||||||
|
|
||||||
|
// Calculate cell dimensions
|
||||||
|
let latHeight = bounds.latMax - bounds.latMin
|
||||||
|
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||||
|
|
||||||
|
// Helper to wrap longitude around ±180
|
||||||
|
func wrapLongitude(_ lon: Double) -> Double {
|
||||||
|
var wrapped = lon
|
||||||
|
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||||
|
while wrapped < -180.0 { wrapped += 360.0 }
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to clamp latitude to ±90
|
||||||
|
func clampLatitude(_ lat: Double) -> Double {
|
||||||
|
return max(-90.0, min(90.0, lat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate 8 neighbor centers
|
||||||
|
let neighbors: [(lat: Double, lon: Double)] = [
|
||||||
|
(center.lat + latHeight, center.lon), // N
|
||||||
|
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||||
|
(center.lat, center.lon + lonWidth), // E
|
||||||
|
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||||
|
(center.lat - latHeight, center.lon), // S
|
||||||
|
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||||
|
(center.lat, center.lon - lonWidth), // W
|
||||||
|
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||||
|
]
|
||||||
|
|
||||||
|
// Encode each neighbor, handling boundary conditions
|
||||||
|
return neighbors.compactMap { neighbor in
|
||||||
|
let lat = clampLatitude(neighbor.lat)
|
||||||
|
let lon = wrapLongitude(neighbor.lon)
|
||||||
|
|
||||||
|
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||||
|
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1493
-373
File diff suppressed because it is too large
Load Diff
@@ -65,9 +65,6 @@ final class CommandProcessor {
|
|||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
//
|
|
||||||
case "/help", "/h":
|
|
||||||
return .error(message: "unknown command: \(cmd)")
|
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
@@ -311,19 +308,4 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHelp() -> CommandResult {
|
|
||||||
let helpText = """
|
|
||||||
commands:
|
|
||||||
/msg @name - start private chat
|
|
||||||
/who - list who's online
|
|
||||||
/clear - clear messages
|
|
||||||
/hug @name - send a hug
|
|
||||||
/slap @name - slap with a trout
|
|
||||||
/fav @name - add to favorites
|
|
||||||
/unfav @name - remove from favorites
|
|
||||||
/block @name - block
|
|
||||||
/unblock @name - unblock
|
|
||||||
"""
|
|
||||||
return .success(message: helpText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
private func isSandboxed() -> Bool {
|
|
||||||
#if os(macOS)
|
|
||||||
// More robust sandbox detection using multiple methods
|
|
||||||
|
|
||||||
// Method 1: Check environment variable (can be spoofed)
|
|
||||||
let environment = ProcessInfo.processInfo.environment
|
|
||||||
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
|
||||||
|
|
||||||
// Method 2: Check if we can access a path outside sandbox
|
|
||||||
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
|
|
||||||
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
|
|
||||||
if canWriteOutsideSandbox {
|
|
||||||
try? FileManager.default.removeItem(at: testPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Check container path
|
|
||||||
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
|
|
||||||
let hasContainerPath = containerPath.contains("/Containers/")
|
|
||||||
|
|
||||||
// If any method indicates sandbox, we consider it sandboxed
|
|
||||||
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
|
|
||||||
#else
|
|
||||||
// iOS is always sandboxed
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
switch status {
|
switch status {
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
break // will compute from location
|
break // will compute from location
|
||||||
default:
|
case .notDetermined, .restricted, .denied:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
if case .location(let ch) = selectedChannel {
|
if case .location(let ch) = selectedChannel {
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct LocationNotesCounterDependencies {
|
|
||||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
|
||||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
|
||||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
|
||||||
|
|
||||||
var relayLookup: RelayLookup
|
|
||||||
var subscribe: Subscribe
|
|
||||||
var unsubscribe: Unsubscribe
|
|
||||||
|
|
||||||
static let live = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { geohash, count in
|
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
|
||||||
},
|
|
||||||
subscribe: { filter, id, relays, handler, onEOSE in
|
|
||||||
NostrRelayManager.shared.subscribe(
|
|
||||||
filter: filter,
|
|
||||||
id: id,
|
|
||||||
relayUrls: relays,
|
|
||||||
handler: handler,
|
|
||||||
onEOSE: onEOSE
|
|
||||||
)
|
|
||||||
},
|
|
||||||
unsubscribe: { id in
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
|
||||||
@MainActor
|
|
||||||
final class LocationNotesCounter: ObservableObject {
|
|
||||||
static let shared = LocationNotesCounter()
|
|
||||||
|
|
||||||
@Published private(set) var geohash: String? = nil
|
|
||||||
@Published private(set) var count: Int? = 0
|
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
|
||||||
@Published private(set) var relayAvailable: Bool = true
|
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
|
||||||
private var noteIDs = Set<String>()
|
|
||||||
private let dependencies: LocationNotesCounterDependencies
|
|
||||||
|
|
||||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
init(testDependencies: LocationNotesCounterDependencies) {
|
|
||||||
self.dependencies = testDependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
|
||||||
let norm = gh.lowercased()
|
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
|
||||||
// Validate geohash (building-level precision: 8 chars)
|
|
||||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
|
||||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = norm
|
|
||||||
noteIDs.removeAll()
|
|
||||||
initialLoadComplete = false
|
|
||||||
relayAvailable = true
|
|
||||||
|
|
||||||
// Subscribe only to the building geohash (precision 8)
|
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
|
||||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else {
|
|
||||||
relayAvailable = false
|
|
||||||
initialLoadComplete = true
|
|
||||||
count = 0
|
|
||||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptionID = subID
|
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
|
||||||
if !self.noteIDs.contains(event.id) {
|
|
||||||
self.noteIDs.insert(event.id)
|
|
||||||
self.count = self.noteIDs.count
|
|
||||||
}
|
|
||||||
}, { [weak self] in
|
|
||||||
self?.initialLoadComplete = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = nil
|
|
||||||
count = 0
|
|
||||||
noteIDs.removeAll()
|
|
||||||
relayAvailable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -163,14 +163,22 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||||
|
let neighbors = Geohash.neighbors(of: geohash)
|
||||||
|
let allGeohashes = [geohash] + neighbors
|
||||||
|
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||||
|
|
||||||
|
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||||
|
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||||
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag - accept any of our 9 geohashes
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
guard event.tags.contains(where: { tag in
|
||||||
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||||
|
}) else { return }
|
||||||
guard !self.noteIDs.contains(event.id) else { return }
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ final class NostrTransport: Transport {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { return }
|
} catch { return }
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { return }
|
guard hrp == "npub" else { return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { return }
|
} catch { return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ extension NostrTransport {
|
|||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
@@ -171,7 +171,7 @@ extension NostrTransport {
|
|||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
@@ -184,7 +184,7 @@ extension NostrTransport {
|
|||||||
guard !recipientHex.isEmpty else { return }
|
guard !recipientHex.isEmpty else { return }
|
||||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ extension NostrTransport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
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)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,19 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct NotificationStreamAssembler {
|
struct NotificationStreamAssembler {
|
||||||
private var buffer = Data()
|
private var buffer = Data()
|
||||||
|
private var pendingFrameStartedAt: DispatchTime?
|
||||||
|
private var pendingFrameExpectedLength: Int = 0
|
||||||
|
|
||||||
|
private mutating func resetState() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
}
|
||||||
|
|
||||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
guard !chunk.isEmpty else { return ([], [], false) }
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
@@ -18,64 +27,107 @@ struct NotificationStreamAssembler {
|
|||||||
|
|
||||||
var frames: [Data] = []
|
var frames: [Data] = []
|
||||||
var dropped: [UInt8] = []
|
var dropped: [UInt8] = []
|
||||||
var reset = false
|
var didReset = false
|
||||||
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
let now = DispatchTime.now()
|
||||||
|
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||||
|
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||||
|
|
||||||
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||||
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||||
|
resetState()
|
||||||
|
return ([], [], true)
|
||||||
|
}
|
||||||
|
|
||||||
while buffer.count >= minFramePrefix {
|
while buffer.count >= minimumFramePrefix {
|
||||||
guard let first = buffer.first else { break }
|
guard let version = buffer.first else { break }
|
||||||
if first != 1 {
|
guard version == 1 || version == 2 else {
|
||||||
dropped.append(buffer.removeFirst())
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
guard buffer.count >= minHeaderBytes else { break }
|
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
||||||
|
guard buffer.count >= framePrefix else { break }
|
||||||
|
|
||||||
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||||
guard headerBytes.count == minFramePrefix else { break }
|
guard flagsIndex < buffer.endIndex else { break }
|
||||||
|
let flags = buffer[flagsIndex]
|
||||||
let flags = headerBytes[11]
|
|
||||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||||
|
|
||||||
var frameLength = minFramePrefix + payloadLen
|
let lengthOffset = 12
|
||||||
|
let payloadLength: Int
|
||||||
|
if version == 2 {
|
||||||
|
let lengthIndex = buffer.startIndex + lengthOffset
|
||||||
|
payloadLength =
|
||||||
|
(Int(buffer[lengthIndex]) << 24) |
|
||||||
|
(Int(buffer[lengthIndex + 1]) << 16) |
|
||||||
|
(Int(buffer[lengthIndex + 2]) << 8) |
|
||||||
|
Int(buffer[lengthIndex + 3])
|
||||||
|
} else {
|
||||||
|
let lengthIndex = buffer.startIndex + lengthOffset
|
||||||
|
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameLength = framePrefix + payloadLength
|
||||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
if isCompressed {
|
||||||
|
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||||
|
if payloadLength < rawLengthFieldBytes {
|
||||||
|
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||||
buffer.removeAll()
|
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||||
reset = true
|
resetState()
|
||||||
|
didReset = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if buffer.count < frameLength {
|
if buffer.count < frameLength {
|
||||||
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
let remaining = frameLength - buffer.count
|
||||||
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||||
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
pendingFrameStartedAt = now
|
||||||
if dropCount > 0 {
|
pendingFrameExpectedLength = frameLength
|
||||||
buffer.removeFirst(dropCount)
|
} else if let started = pendingFrameStartedAt {
|
||||||
dropped.append(1) // treat as dropped partial start
|
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
||||||
|
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
||||||
|
if elapsed >= threshold {
|
||||||
|
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
||||||
|
resetState()
|
||||||
|
didReset = true
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
|
||||||
let frame = Data(buffer.prefix(frameLength))
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
frames.append(frame)
|
frames.append(frame)
|
||||||
buffer.removeFirst(frameLength)
|
buffer.removeFirst(frameLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
buffer.removeAll(keepingCapacity: false)
|
resetState()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (frames, dropped, reset)
|
return (frames, dropped, didReset)
|
||||||
}
|
|
||||||
|
|
||||||
mutating func reset() {
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID.id ?? "",
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Centralized progress bus for Bluetooth file transfers.
|
||||||
|
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
|
||||||
|
final class TransferProgressManager {
|
||||||
|
static let shared = TransferProgressManager()
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case started(id: String, totalFragments: Int)
|
||||||
|
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
case completed(id: String, totalFragments: Int)
|
||||||
|
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let subject = PassthroughSubject<Event, Never>()
|
||||||
|
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
|
||||||
|
private var states: [String: (sent: Int, total: Int)] = [:]
|
||||||
|
|
||||||
|
var publisher: AnyPublisher<Event, Never> {
|
||||||
|
subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(id: String, totalFragments: Int) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.states[id] = (sent: 0, total: totalFragments)
|
||||||
|
self.subject.send(.started(id: id, totalFragments: totalFragments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordFragmentSent(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self, var state = self.states[id] else { return }
|
||||||
|
state.sent = min(state.sent + 1, state.total)
|
||||||
|
self.states[id] = state
|
||||||
|
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
if state.sent >= state.total {
|
||||||
|
self.states.removeValue(forKey: id)
|
||||||
|
self.subject.send(.completed(id: id, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
|
||||||
|
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(id: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.states.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||||
|
var result: (sent: Int, total: Int)?
|
||||||
|
queue.sync {
|
||||||
|
result = states[id]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ protocol Transport: AnyObject {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||||
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
@@ -59,6 +62,9 @@ protocol Transport: AnyObject {
|
|||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
|
func cancelTransfer(_ transferId: String) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ enum TransportConfig {
|
|||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
static let bleConnectionCandidatesMax: Int = 100
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||||
static let blePendingNotificationsCapCount: Int = 20
|
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
||||||
|
static let bleAssemblerStallResetMs: Int = 250
|
||||||
|
static let blePendingNotificationsCapCount: Int = 128
|
||||||
|
static let bleNotificationRetryDelayMs: Int = 25
|
||||||
|
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
@@ -141,6 +145,9 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Geo relay directory
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
|
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
||||||
|
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
||||||
|
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
||||||
|
|
||||||
// BLE operational delays
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
|
||||||
|
enum FileTransferLimits {
|
||||||
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Voice notes stay small for low-latency relays.
|
||||||
|
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
|
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
|
static let maxFramedFileBytes: Int = {
|
||||||
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
|
||||||
|
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
|
||||||
|
+ BinaryProtocol.senderIDSize
|
||||||
|
+ BinaryProtocol.recipientIDSize
|
||||||
|
+ BinaryProtocol.signatureSize
|
||||||
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func isValidPayload(_ size: Int) -> Bool {
|
||||||
|
size <= maxPayloadBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1289
-364
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FingerprintView: View {
|
struct FingerprintView: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -65,15 +65,15 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: String = {
|
let statusPeerID: PeerID = {
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
if peerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID.id) { return short }
|
||||||
return peerID
|
return peerID
|
||||||
}()
|
}()
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if let data = peerID.noiseKey {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
||||||
@@ -84,7 +84,7 @@ struct FingerprintView: View {
|
|||||||
return Strings.unknownPeer()
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -115,7 +115,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -176,7 +176,6 @@ struct FingerprintView: View {
|
|||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
let peerID = PeerID(str: peerID)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
@@ -240,8 +239,6 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||||
|
|||||||
@@ -125,9 +125,6 @@ struct LocationChannelsSheet: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
@@ -599,7 +596,7 @@ extension LocationChannelsSheet {
|
|||||||
switch level {
|
switch level {
|
||||||
case .region:
|
case .region:
|
||||||
return ""
|
return ""
|
||||||
default:
|
case .building, .block, .neighborhood, .city, .province:
|
||||||
return "~"
|
return "~"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ struct LocationNotesView: View {
|
|||||||
.navigationTitle("")
|
.navigationTitle("")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
#endif
|
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
@@ -141,7 +138,7 @@ struct LocationNotesView: View {
|
|||||||
String(
|
String(
|
||||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||||
locale: .current,
|
locale: .current,
|
||||||
geohash, count
|
"\(geohash) ± 1", count
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
private typealias PlatformImage = UIImage
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
private typealias PlatformImage = NSImage
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct BlockRevealImageView: View {
|
||||||
|
private let url: URL
|
||||||
|
private let revealProgress: Double?
|
||||||
|
private let isSending: Bool
|
||||||
|
private let onCancel: (() -> Void)?
|
||||||
|
private let initiallyBlurred: Bool
|
||||||
|
private let onOpen: (() -> Void)?
|
||||||
|
private let onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
@State private var platformImage: PlatformImage?
|
||||||
|
@State private var aspectRatio: CGFloat = 1
|
||||||
|
@State private var isBlurred: Bool = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
revealProgress: Double?,
|
||||||
|
isSending: Bool,
|
||||||
|
onCancel: (() -> Void)?,
|
||||||
|
initiallyBlurred: Bool = false,
|
||||||
|
onOpen: (() -> Void)? = nil,
|
||||||
|
onDelete: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.revealProgress = revealProgress
|
||||||
|
self.isSending = isSending
|
||||||
|
self.onCancel = onCancel
|
||||||
|
self.initiallyBlurred = initiallyBlurred
|
||||||
|
self.onOpen = onOpen
|
||||||
|
self.onDelete = onDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fraction: Double {
|
||||||
|
guard let revealProgress = revealProgress else { return 1 }
|
||||||
|
return max(0, min(1, revealProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
if let image = platformImage {
|
||||||
|
Image(platformImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.mask(
|
||||||
|
BlockRevealMask(
|
||||||
|
fraction: fraction,
|
||||||
|
columns: 24,
|
||||||
|
rows: 16
|
||||||
|
)
|
||||||
|
.animation(.easeOut(duration: 0.2), value: fraction)
|
||||||
|
)
|
||||||
|
.blur(radius: isBlurred ? 20 : 0)
|
||||||
|
.overlay {
|
||||||
|
if isBlurred {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color.black.opacity(0.35))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "eye.slash.fill")
|
||||||
|
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.frame(height: 200)
|
||||||
|
.overlay(
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let onCancel = onCancel, isSending {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
||||||
|
.padding(8)
|
||||||
|
.background(Circle().fill(Color.black.opacity(0.7)))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
isBlurred = initiallyBlurred
|
||||||
|
loadImage()
|
||||||
|
}
|
||||||
|
.onChange(of: url) { _ in
|
||||||
|
isBlurred = initiallyBlurred
|
||||||
|
loadImage()
|
||||||
|
}
|
||||||
|
.gesture(mainGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.platformImage = image
|
||||||
|
self.aspectRatio = ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainGesture: some Gesture {
|
||||||
|
let doubleTap = TapGesture(count: 2).onEnded {
|
||||||
|
guard !isSending else { return }
|
||||||
|
onDelete?()
|
||||||
|
}
|
||||||
|
let singleTap = TapGesture().onEnded {
|
||||||
|
guard !isSending else { return }
|
||||||
|
if isBlurred {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
isBlurred = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onOpen?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
||||||
|
guard !isSending else { return }
|
||||||
|
let horizontal = value.translation.width
|
||||||
|
let vertical = value.translation.height
|
||||||
|
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
||||||
|
if !isBlurred {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isBlurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BlockRevealMask: Shape {
|
||||||
|
let fraction: Double
|
||||||
|
let columns: Int
|
||||||
|
let rows: Int
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
guard fraction > 0, columns > 0, rows > 0 else { return path }
|
||||||
|
let totalBlocks = columns * rows
|
||||||
|
let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks)))))
|
||||||
|
guard revealCount > 0 else { return path }
|
||||||
|
let blockWidth = rect.width / CGFloat(columns)
|
||||||
|
let blockHeight = rect.height / CGFloat(rows)
|
||||||
|
var remaining = revealCount
|
||||||
|
for row in 0..<rows {
|
||||||
|
for column in 0..<columns {
|
||||||
|
if remaining <= 0 { return path }
|
||||||
|
let x = CGFloat(column) * blockWidth
|
||||||
|
let y = CGFloat(row) * blockHeight
|
||||||
|
path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Image {
|
||||||
|
init(platformImage: PlatformImage) {
|
||||||
|
#if os(iOS)
|
||||||
|
self.init(uiImage: platformImage)
|
||||||
|
#else
|
||||||
|
self.init(nsImage: platformImage)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct VoiceNoteView: View {
|
||||||
|
private let url: URL
|
||||||
|
private let isSending: Bool
|
||||||
|
private let sendProgress: Double?
|
||||||
|
private let onCancel: (() -> Void)?
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@StateObject private var playback: VoiceNotePlaybackController
|
||||||
|
@State private var waveform: [Float] = []
|
||||||
|
|
||||||
|
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
||||||
|
self.url = url
|
||||||
|
self.isSending = isSending
|
||||||
|
self.sendProgress = sendProgress
|
||||||
|
self.onCancel = onCancel
|
||||||
|
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var samples: [Float] {
|
||||||
|
if waveform.isEmpty {
|
||||||
|
return Array(repeating: 0.25, count: 64)
|
||||||
|
}
|
||||||
|
return waveform
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
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 {
|
||||||
|
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: playback.togglePlayback) {
|
||||||
|
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Circle().fill(Color.green))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
WaveformView(
|
||||||
|
samples: samples,
|
||||||
|
playbackProgress: playback.progress,
|
||||||
|
sendProgress: sendProgress,
|
||||||
|
onSeek: { fraction in
|
||||||
|
playback.seek(to: fraction)
|
||||||
|
},
|
||||||
|
isInteractive: playback.isPlaying
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(playbackLabel)
|
||||||
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||||
|
.foregroundColor(Color.secondary)
|
||||||
|
|
||||||
|
if let onCancel = onCancel, isSending {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Circle().fill(Color.red.opacity(0.9)))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(borderColor, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.task {
|
||||||
|
// Defer loading to let UI settle after view appears
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
||||||
|
playback.loadDuration()
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
WaveformCache.shared.waveform(for: url, completion: { bins in
|
||||||
|
waveform = bins
|
||||||
|
continuation.resume()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: url) { newValue in
|
||||||
|
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
||||||
|
self.waveform = bins
|
||||||
|
})
|
||||||
|
playback.replaceURL(newValue)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
playback.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WaveformView: View {
|
||||||
|
let samples: [Float]
|
||||||
|
let playbackProgress: Double
|
||||||
|
let sendProgress: Double?
|
||||||
|
let onSeek: ((Double) -> Void)?
|
||||||
|
let isInteractive: Bool
|
||||||
|
|
||||||
|
private var clampedPlayback: Double {
|
||||||
|
max(0, min(1, playbackProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clampedSend: Double? {
|
||||||
|
guard let sendProgress = sendProgress else { return nil }
|
||||||
|
return max(0, min(1, sendProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Canvas { context, size in
|
||||||
|
guard !samples.isEmpty else { return }
|
||||||
|
let width = max(size.width, 1)
|
||||||
|
let height = max(size.height, 1)
|
||||||
|
let barWidth = max(width / CGFloat(samples.count), 1)
|
||||||
|
for (index, sample) in samples.enumerated() {
|
||||||
|
let normalized = max(0, min(sample, 1))
|
||||||
|
let barHeight = CGFloat(normalized) * height
|
||||||
|
let originX = CGFloat(index) * barWidth
|
||||||
|
let rect = CGRect(
|
||||||
|
x: originX,
|
||||||
|
y: (height - barHeight) / 2,
|
||||||
|
width: max(barWidth * 0.7, 1),
|
||||||
|
height: barHeight
|
||||||
|
)
|
||||||
|
let binPosition = Double(index) / Double(samples.count)
|
||||||
|
let color: Color
|
||||||
|
if binPosition <= clampedPlayback {
|
||||||
|
color = Color.green
|
||||||
|
} else if let send = clampedSend, binPosition <= send {
|
||||||
|
color = Color.blue
|
||||||
|
} else {
|
||||||
|
color = Color.gray.opacity(0.35)
|
||||||
|
}
|
||||||
|
context.fill(Path(rect), with: .color(color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
|
|
||||||
|
if isInteractive, let onSeek = onSeek {
|
||||||
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onEnded { value in
|
||||||
|
guard geometry.size.width > 0 else { return }
|
||||||
|
let fraction = max(0, min(1, value.location.x / geometry.size.width))
|
||||||
|
onSeek(fraction)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
|
|||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPeer: (String) -> Void
|
let onTapPeer: (PeerID) -> Void
|
||||||
let onToggleFavorite: (String) -> Void
|
let onToggleFavorite: (PeerID) -> Void
|
||||||
let onShowFingerprint: (String) -> Void
|
let onShowFingerprint: (PeerID) -> Void
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
Button(action: { onToggleFavorite(peer.peerID) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
|
|||||||
@@ -388,10 +388,6 @@ struct VerificationSheetView: View {
|
|||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
#endif
|
|
||||||
.onDisappear { showingScanner = false }
|
.onDisappear { showingScanner = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,19 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.microphone</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.assets.pictures.read-only</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -93,6 +93,62 @@ struct FragmentationTests {
|
|||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Max-sized file transfer survives reassembly")
|
||||||
|
func maxSizedFileTransferSurvivesReassembly() async throws {
|
||||||
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
|
let capture = CaptureDelegate()
|
||||||
|
ble.delegate = capture
|
||||||
|
|
||||||
|
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
|
||||||
|
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
|
||||||
|
let filePacket = BitchatFilePacket(
|
||||||
|
fileName: "limit.bin",
|
||||||
|
fileSize: UInt64(fileContent.count),
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
content: fileContent
|
||||||
|
)
|
||||||
|
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
senderID: Data(hexString: remoteID.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: encoded,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7,
|
||||||
|
version: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
|
||||||
|
#expect(!fragments.isEmpty)
|
||||||
|
|
||||||
|
for (i, fragment) in fragments.enumerated() {
|
||||||
|
let delay = 5 * Double(i) * 0.001
|
||||||
|
Task {
|
||||||
|
try await sleep(delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await sleep(1.0)
|
||||||
|
|
||||||
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
||||||
|
#expect(message.content.hasPrefix("[file]"))
|
||||||
|
|
||||||
|
if let fileName = message.content.split(separator: " ").last {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
|
||||||
|
let url = incoming.appendingPathComponent(String(fileName))
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
let ble = BLEService(
|
let ble = BLEService(
|
||||||
@@ -142,7 +198,10 @@ struct FragmentationTests {
|
|||||||
extension FragmentationTests {
|
extension FragmentationTests {
|
||||||
private final class CaptureDelegate: BitchatDelegate {
|
private final class CaptureDelegate: BitchatDelegate {
|
||||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
var receivedMessages: [BitchatMessage] = []
|
||||||
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
|
receivedMessages.append(message)
|
||||||
|
}
|
||||||
func didConnectToPeer(_ peerID: PeerID) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
@@ -173,8 +232,8 @@ extension FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: fragment a packet using the same header format BLEService expects
|
// Helper: fragment a packet using the same header format BLEService expects
|
||||||
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
|
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
|
||||||
let fullData = packet.toBinaryData() ?? Data()
|
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
||||||
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||||
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
||||||
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
||||||
|
|||||||
@@ -91,61 +91,3 @@ struct LocationNotesManagerTests {
|
|||||||
case shouldNotDerive
|
case shouldNotDerive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct LocationNotesCounterTests {
|
|
||||||
@Test func subscribeWithoutRelaysMarksUnavailable() {
|
|
||||||
var subscribeCalled = false
|
|
||||||
let deps = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { _, _ in [] },
|
|
||||||
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
|
||||||
unsubscribe: { _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
|
||||||
|
|
||||||
#expect(!subscribeCalled)
|
|
||||||
#expect(!counter.relayAvailable)
|
|
||||||
#expect(counter.initialLoadComplete)
|
|
||||||
#expect(counter.count == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeCountsUniqueNotes() {
|
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
|
||||||
var storedEOSE: (() -> Void)?
|
|
||||||
let deps = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { _, _ in ["wss://relay.geo"] },
|
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
|
||||||
#expect(relays == ["wss://relay.geo"])
|
|
||||||
#expect(filter.kinds == [1])
|
|
||||||
#expect(!id.isEmpty)
|
|
||||||
storedHandler = handler
|
|
||||||
storedEOSE = eose
|
|
||||||
},
|
|
||||||
unsubscribe: { _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
|
||||||
|
|
||||||
var first = NostrEvent(
|
|
||||||
pubkey: "pub",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .textNote,
|
|
||||||
tags: [["g", "u4pruydq"]],
|
|
||||||
content: "a"
|
|
||||||
)
|
|
||||||
first.id = "eventA"
|
|
||||||
storedHandler?(first)
|
|
||||||
|
|
||||||
let duplicate = first
|
|
||||||
storedHandler?(duplicate)
|
|
||||||
|
|
||||||
storedEOSE?()
|
|
||||||
|
|
||||||
#expect(counter.relayAvailable)
|
|
||||||
#expect(counter.count == 1)
|
|
||||||
#expect(counter.initialLoadComplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
|
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
|
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
|
||||||
|
}
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ struct NostrProtocolTests {
|
|||||||
|
|
||||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||||
let messageID = "TEST-MSG-DELIVERED-1"
|
let messageID = "TEST-MSG-DELIVERED-1"
|
||||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
|
||||||
|
|
||||||
let embedded = try #require(
|
let embedded = try #require(
|
||||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
@@ -176,7 +176,7 @@ struct NostrProtocolTests {
|
|||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let messageID = "TEST-MSG-READ-1"
|
let messageID = "TEST-MSG-READ-1"
|
||||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
|
||||||
let embedded = try #require(
|
let embedded = try #require(
|
||||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
"Failed to embed read ack"
|
"Failed to embed read ack"
|
||||||
|
|||||||
@@ -95,4 +95,57 @@ struct NotificationStreamAssemblerTests {
|
|||||||
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
|
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
|
||||||
#expect(decoded.timestamp == packet.timestamp)
|
#expect(decoded.timestamp == packet.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAssemblesCompressedLargeFrame() throws {
|
||||||
|
var assembler = NotificationStreamAssembler()
|
||||||
|
|
||||||
|
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
|
||||||
|
let largeContent = Data(repeating: 0x41, count: 600_000)
|
||||||
|
let filePacket = BitchatFilePacket(
|
||||||
|
fileName: "large.bin",
|
||||||
|
fileSize: UInt64(largeContent.count),
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
content: largeContent
|
||||||
|
)
|
||||||
|
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
|
||||||
|
|
||||||
|
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.fileTransfer.rawValue,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: 0x010203040506,
|
||||||
|
payload: tlvPayload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3,
|
||||||
|
version: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet frame")
|
||||||
|
|
||||||
|
#expect(BinaryProtocol.Offsets.flags < frame.count)
|
||||||
|
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
|
||||||
|
#expect((flags & BinaryProtocol.Flags.isCompressed) != 0, "Frame should be compressed for large payloads")
|
||||||
|
|
||||||
|
let splitIndex = min(4096, frame.count / 2)
|
||||||
|
var result = assembler.append(frame.prefix(splitIndex))
|
||||||
|
#expect(result.frames.isEmpty)
|
||||||
|
|
||||||
|
result = assembler.append(frame.suffix(from: splitIndex))
|
||||||
|
#expect(result.frames.count == 1)
|
||||||
|
#expect(result.droppedPrefixes.isEmpty)
|
||||||
|
#expect(result.reset == false)
|
||||||
|
|
||||||
|
let assembled = try #require(result.frames.first, "Missing assembled frame")
|
||||||
|
#expect(assembled.count == frame.count)
|
||||||
|
|
||||||
|
let decodedPacket = try #require(BinaryProtocol.decode(assembled), "Failed to decode compressed frame")
|
||||||
|
#expect(decodedPacket.payload.count == tlvPayload.count)
|
||||||
|
|
||||||
|
let decodedFile = try #require(BitchatFilePacket.decode(decodedPacket.payload), "Failed to decode TLV payload")
|
||||||
|
#expect(decodedFile.fileName == filePacket.fileName)
|
||||||
|
#expect(decodedFile.mimeType == filePacket.mimeType)
|
||||||
|
#expect(decodedFile.content.count == largeContent.count)
|
||||||
|
#expect(decodedFile.content.prefix(32) == largeContent.prefix(32))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ struct BinaryProtocolTests {
|
|||||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
||||||
|
|
||||||
// The encoded size should be smaller than uncompressed due to compression
|
// The encoded size should be smaller than uncompressed due to compression
|
||||||
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
|
||||||
#expect(encodedData.count < uncompressedSize)
|
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||||
|
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
|
||||||
|
|
||||||
// Decode and verify
|
// Decode and verify
|
||||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class BitchatFilePacketTests: XCTestCase {
|
||||||
|
|
||||||
|
func testRoundTripPreservesFields() throws {
|
||||||
|
let content = Data((0..<4096).map { UInt8($0 % 251) })
|
||||||
|
let packet = BitchatFilePacket(
|
||||||
|
fileName: "sample.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let encoded = packet.encode() else {
|
||||||
|
return XCTFail("Failed to encode file packet")
|
||||||
|
}
|
||||||
|
guard let decoded = BitchatFilePacket.decode(encoded) else {
|
||||||
|
return XCTFail("Failed to decode file packet")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.fileName, packet.fileName)
|
||||||
|
XCTAssertEqual(decoded.fileSize, packet.fileSize)
|
||||||
|
XCTAssertEqual(decoded.mimeType, packet.mimeType)
|
||||||
|
XCTAssertEqual(decoded.content, packet.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeFallsBackToContentSizeWhenFileSizeMissing() throws {
|
||||||
|
let content = Data(repeating: 0x7F, count: 1024)
|
||||||
|
let packet = BitchatFilePacket(
|
||||||
|
fileName: nil,
|
||||||
|
fileSize: nil,
|
||||||
|
mimeType: nil,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let encoded = packet.encode() else {
|
||||||
|
return XCTFail("Failed to encode file packet")
|
||||||
|
}
|
||||||
|
guard let decoded = BitchatFilePacket.decode(encoded) else {
|
||||||
|
return XCTFail("Failed to decode file packet")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||||
|
XCTAssertEqual(decoded.content, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "BitLogger",
|
name: "BitLogger",
|
||||||
path: "Sources"
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "BitLoggerTests",
|
||||||
|
dependencies: ["BitLogger"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
#if canImport(os.log)
|
||||||
import os.log
|
import os.log
|
||||||
|
#endif
|
||||||
|
|
||||||
public extension OSLog {
|
public extension OSLog {
|
||||||
private static let subsystem = "chat.bitchat"
|
private static let subsystem = "chat.bitchat"
|
||||||
|
|||||||
@@ -7,7 +7,53 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(os.log)
|
||||||
import os.log
|
import os.log
|
||||||
|
#else
|
||||||
|
public struct OSLog {
|
||||||
|
public let subsystem: String
|
||||||
|
public let category: String
|
||||||
|
|
||||||
|
public init(subsystem: String, category: String) {
|
||||||
|
self.subsystem = subsystem
|
||||||
|
self.category = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OSLogType: CustomStringConvertible {
|
||||||
|
private let label: String
|
||||||
|
|
||||||
|
private init(_ label: String) {
|
||||||
|
self.label = label
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String { label }
|
||||||
|
|
||||||
|
public static let debug = OSLogType("debug")
|
||||||
|
public static let info = OSLogType("info")
|
||||||
|
public static let `default` = OSLogType("default")
|
||||||
|
public static let error = OSLogType("error")
|
||||||
|
public static let fault = OSLogType("fault")
|
||||||
|
}
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
let secureLoggerFallbackFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
@usableFromInline
|
||||||
|
func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {
|
||||||
|
let rawFormat = String(describing: message)
|
||||||
|
let format = rawFormat
|
||||||
|
.replacingOccurrences(of: "%{public}@", with: "%@")
|
||||||
|
.replacingOccurrences(of: "%{private}@", with: "%@")
|
||||||
|
let formatted = String(format: format, arguments: args)
|
||||||
|
let timestamp = secureLoggerFallbackFormatter.string(from: Date())
|
||||||
|
print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Centralized security-aware logging framework
|
/// Centralized security-aware logging framework
|
||||||
/// Provides safe logging that filters sensitive data and security events
|
/// Provides safe logging that filters sensitive data and security events
|
||||||
@@ -22,22 +68,6 @@ public final class SecureLogger {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Cached Regex Patterns
|
|
||||||
|
|
||||||
private static let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
|
||||||
private static let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
|
||||||
private static let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
|
||||||
private static let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
|
||||||
|
|
||||||
// MARK: - Sanitization Cache
|
|
||||||
|
|
||||||
private static let sanitizationCache: NSCache<NSString, NSString> = {
|
|
||||||
let cache = NSCache<NSString, NSString>()
|
|
||||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
|
||||||
return cache
|
|
||||||
}()
|
|
||||||
private static let cacheQueue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
|
||||||
|
|
||||||
// MARK: - Log Levels
|
// MARK: - Log Levels
|
||||||
|
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
@@ -115,8 +145,8 @@ public extension SecureLogger {
|
|||||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize(context())
|
let sanitized = context().sanitized()
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
let errorDesc = error.localizedDescription.sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
@@ -140,15 +170,15 @@ public extension SecureLogger {
|
|||||||
var message: String {
|
var message: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .handshakeStarted(let peerID):
|
case .handshakeStarted(let peerID):
|
||||||
return "Handshake started with peer: \(sanitize(peerID))"
|
return "Handshake started with peer: \(peerID.sanitized())"
|
||||||
case .handshakeCompleted(let peerID):
|
case .handshakeCompleted(let peerID):
|
||||||
return "Handshake completed with peer: \(sanitize(peerID))"
|
return "Handshake completed with peer: \(peerID.sanitized())"
|
||||||
case .handshakeFailed(let peerID, let error):
|
case .handshakeFailed(let peerID, let error):
|
||||||
return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)"
|
return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)"
|
||||||
case .sessionExpired(let peerID):
|
case .sessionExpired(let peerID):
|
||||||
return "Session expired for peer: \(sanitize(peerID))"
|
return "Session expired for peer: \(peerID.sanitized())"
|
||||||
case .authenticationFailed(let peerID):
|
case .authenticationFailed(let peerID):
|
||||||
return "Authentication failed for peer: \(sanitize(peerID))"
|
return "Authentication failed for peer: \(peerID.sanitized())"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +233,7 @@ private extension SecureLogger {
|
|||||||
file: String, line: Int, function: String) {
|
file: String, line: Int, function: String) {
|
||||||
guard shouldLog(level) else { return }
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize("\(location) \(message())")
|
let sanitized = "\(location) \(message())".sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
@@ -236,58 +266,6 @@ private extension SecureLogger {
|
|||||||
let timestamp = timestampFormatter.string(from: Date())
|
let timestamp = timestampFormatter.string(from: Date())
|
||||||
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitize strings to remove potentially sensitive data
|
|
||||||
static func sanitize(_ input: String) -> String {
|
|
||||||
let key = input as NSString
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
var cachedValue: String?
|
|
||||||
cacheQueue.sync {
|
|
||||||
cachedValue = sanitizationCache.object(forKey: key) as String?
|
|
||||||
}
|
|
||||||
|
|
||||||
if let cached = cachedValue {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform sanitization
|
|
||||||
var sanitized = input
|
|
||||||
|
|
||||||
// Remove full fingerprints (keep first 8 chars for debugging)
|
|
||||||
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
|
||||||
let fingerprint = String(match.output)
|
|
||||||
return String(fingerprint.prefix(8)) + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove base64 encoded data that might be keys
|
|
||||||
sanitized = sanitized.replacing(base64Pattern) { _ in
|
|
||||||
"<base64-data>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
|
||||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
|
||||||
"password: <redacted>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate peer IDs to first 8 characters
|
|
||||||
sanitized = sanitized.replacing(peerIDPattern) { match in
|
|
||||||
"peerID: \(match.1)..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
cacheQueue.sync {
|
|
||||||
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize individual values
|
|
||||||
static func sanitize<T>(_ value: T) -> String {
|
|
||||||
let stringValue = String(describing: value)
|
|
||||||
return sanitize(stringValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Migration Helper
|
// MARK: - Migration Helper
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// String+Sanitization.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// Sanitize strings to remove potentially sensitive data
|
||||||
|
func sanitized() -> String {
|
||||||
|
let key = self as NSString
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) {
|
||||||
|
return cached as String
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized = self
|
||||||
|
|
||||||
|
// Remove full fingerprints (keep first 8 chars for debugging)
|
||||||
|
let fingerprintPattern = #/[a-fA-F0-9]{64}/#
|
||||||
|
sanitized = sanitized.replacing(fingerprintPattern) { match in
|
||||||
|
let fingerprint = String(match.output)
|
||||||
|
return String(fingerprint.prefix(8)) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove base64 encoded data that might be keys
|
||||||
|
let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
|
||||||
|
sanitized = sanitized.replacing(base64Pattern) { _ in
|
||||||
|
"<base64-data>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||||
|
let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||||
|
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||||
|
"password: <redacted>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate peer IDs to first 8 characters
|
||||||
|
let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
|
||||||
|
sanitized = sanitized.replacing(peerIDPattern) { match in
|
||||||
|
"peerID: \(match.1)..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
Self.queue.sync {
|
||||||
|
Self.cache.setObject(sanitized as NSString, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Helpers
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
|
||||||
|
|
||||||
|
static let cache: NSCache<NSString, NSString> = {
|
||||||
|
let cache = NSCache<NSString, NSString>()
|
||||||
|
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||||
|
return cache
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// StringSanitizationTests.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// Created by Islam on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import BitLogger
|
||||||
|
|
||||||
|
struct StringSanitizationTests {
|
||||||
|
|
||||||
|
@Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis")
|
||||||
|
func fingerprintTruncation() async throws {
|
||||||
|
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
#expect(fingerprint.count == 64)
|
||||||
|
|
||||||
|
let input = "fingerprint=\(fingerprint)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
|
||||||
|
#expect(output.contains("fingerprint=01234567..."))
|
||||||
|
// Ensure no full fingerprint remains
|
||||||
|
#expect(output.contains(fingerprint) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Multiple fingerprints in a string are all truncated")
|
||||||
|
func multipleFingerprintTruncation() async throws {
|
||||||
|
let fp1 = String(repeating: "a", count: 64)
|
||||||
|
let fp2 = String(repeating: "b", count: 64)
|
||||||
|
let input = "fp1=\(fp1) fp2=\(fp2)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output.contains("fp1=aaaaaaaa..."))
|
||||||
|
#expect(output.contains("fp2=bbbbbbbb..."))
|
||||||
|
#expect(output.contains(fp1) == false)
|
||||||
|
#expect(output.contains(fp2) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Base64-like long data is replaced with <base64-data>")
|
||||||
|
func base64Replacement() async throws {
|
||||||
|
// 44+ chars of base64 characters
|
||||||
|
let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE="
|
||||||
|
let input = "payload=\(base64ish)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "payload=<base64-data>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Base64-like without padding is replaced with <base64-data>")
|
||||||
|
func base64NoPaddingReplacement() async throws {
|
||||||
|
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
#expect(base64ish.count >= 40)
|
||||||
|
let input = "b64:\(base64ish)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "b64:<base64-data>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Short base64-like strings (below threshold) are not replaced")
|
||||||
|
func shortBase64NotReplaced() async throws {
|
||||||
|
let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars
|
||||||
|
let input = "payload=\(short)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Password redaction for key:value formats", arguments: [
|
||||||
|
"password: secret123",
|
||||||
|
"password=secret123",
|
||||||
|
"password = secret123",
|
||||||
|
"password: 'secret123'",
|
||||||
|
"password:\"secret123\"",
|
||||||
|
"password='secret123'"
|
||||||
|
])
|
||||||
|
func passwordRedactionKeyValue(password: String) async throws {
|
||||||
|
#expect(password.sanitized() == "password: <redacted>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Password redaction inside wider messages")
|
||||||
|
func passwordRedactionInContext() async throws {
|
||||||
|
let input = "user=john password: 'p@ssW0rd' attempt=1"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "user=john password: <redacted> attempt=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PeerID is truncated to first 8 chars followed by ellipsis")
|
||||||
|
func peerIDTruncation() async throws {
|
||||||
|
let peer = "ABCDEF12GHIJKL34"
|
||||||
|
let input = "peerID: \(peer)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == "peerID: ABCDEF12...")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PeerID not truncated when exactly 8 chars")
|
||||||
|
func peerIDExactlyEightNotTruncated() async throws {
|
||||||
|
let peer = "ABCDEF12"
|
||||||
|
let input = "peerID: \(peer)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
// Pattern only matches when there are more than 8 trailing chars, so unchanged
|
||||||
|
#expect(output == input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Non-matching content remains unchanged")
|
||||||
|
func nonMatchingUnchanged() async throws {
|
||||||
|
let input = "Hello world 123 - nothing sensitive here."
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output == input)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Idempotency: sanitizing twice yields same result")
|
||||||
|
func idempotentSanitization() async throws {
|
||||||
|
let input = """
|
||||||
|
fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
|
||||||
|
password: "superSecret" \
|
||||||
|
peerID: ZYXWVUT987654321 \
|
||||||
|
payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
|
||||||
|
"""
|
||||||
|
let once = input.sanitized()
|
||||||
|
let twice = once.sanitized()
|
||||||
|
#expect(once == twice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mixed content: all rules apply in a single string")
|
||||||
|
func mixedContent() async throws {
|
||||||
|
let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||||
|
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
let peer = "PEERID01EXTRA"
|
||||||
|
let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)"
|
||||||
|
let output = input.sanitized()
|
||||||
|
#expect(output.contains("fp=fedcba98..."))
|
||||||
|
#expect(output.contains("password: <redacted>"))
|
||||||
|
#expect(output.contains("peerID: PEERID01..."))
|
||||||
|
#expect(output.contains("data=<base64-data>"))
|
||||||
|
#expect(output.contains(fingerprint) == false)
|
||||||
|
#expect(output.contains(base64ish) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cache returns consistent result for repeated inputs")
|
||||||
|
func cacheHitConsistency() async throws {
|
||||||
|
let input = "password: hunter2"
|
||||||
|
let first = input.sanitized()
|
||||||
|
let second = input.sanitized()
|
||||||
|
#expect(first == "password: <redacted>")
|
||||||
|
#expect(first == second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(Network)
|
||||||
import Network
|
import Network
|
||||||
|
#endif
|
||||||
|
#if canImport(Darwin)
|
||||||
import Darwin
|
import Darwin
|
||||||
|
#elseif canImport(Glibc)
|
||||||
|
import Glibc
|
||||||
|
#endif
|
||||||
|
|
||||||
// Declare C entrypoint for Tor when statically linked from an xcframework.
|
#if !canImport(Network)
|
||||||
@_silgen_name("tor_main")
|
private final class NWPathMonitor {
|
||||||
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
var pathUpdateHandler: ((Any) -> Void)?
|
||||||
|
|
||||||
|
func start(queue: DispatchQueue) {
|
||||||
|
// Path monitoring is unavailable on this platform; nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
|
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
|
||||||
@_silgen_name("tor_host_start")
|
@_silgen_name("tor_host_start")
|
||||||
@@ -286,150 +298,6 @@ public final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dynamic loader path (no Swift module required)
|
|
||||||
|
|
||||||
/// Attempt to locate an embedded tor framework binary and launch Tor via `tor_run_main`.
|
|
||||||
/// Returns true if the attempt started and port probing was scheduled.
|
|
||||||
private func startTorViaDlopen() -> Bool {
|
|
||||||
guard let fwURL = frameworkBinaryURL() else {
|
|
||||||
SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the library
|
|
||||||
let mode = RTLD_NOW | RTLD_LOCAL
|
|
||||||
SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
|
|
||||||
guard let handle = dlopen(fwURL.path, mode) else {
|
|
||||||
let err = String(cString: dlerror())
|
|
||||||
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
|
|
||||||
self.isStarting = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve tor_main(argc, argv)
|
|
||||||
typealias TorMainType = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
|
|
||||||
guard let sym = dlsym(handle, "tor_main") else {
|
|
||||||
// Keep handle open but report error
|
|
||||||
let err = String(cString: dlerror())
|
|
||||||
self.lastError = NSError(domain: "TorManager", code: -11, userInfo: [NSLocalizedDescriptionKey: "dlsym tor_main failed: \(err)"])
|
|
||||||
self.isStarting = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let torMain = unsafeBitCast(sym, to: TorMainType.self)
|
|
||||||
self._dlHandle = handle
|
|
||||||
|
|
||||||
// Prepare args: tor -f <torrc>
|
|
||||||
var argv: [String] = ["tor"]
|
|
||||||
if let torrc = torrcURL()?.path {
|
|
||||||
argv.append(contentsOf: ["-f", torrc])
|
|
||||||
}
|
|
||||||
// Run Tor on a background thread to avoid blocking the main actor
|
|
||||||
SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
|
|
||||||
let argc = Int32(argv.count)
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
// Build stable C argv in this thread
|
|
||||||
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
|
||||||
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
|
|
||||||
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
|
|
||||||
cArgv[cStrings.count] = nil
|
|
||||||
|
|
||||||
_ = torMain(argc, cArgv)
|
|
||||||
|
|
||||||
// Free args after exit (Tor usually never returns)
|
|
||||||
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
|
|
||||||
cArgv.deallocate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start control-port monitor and probe readiness asynchronously
|
|
||||||
startControlMonitorIfNeeded()
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
|
||||||
await MainActor.run {
|
|
||||||
self.socksReady = ready
|
|
||||||
if !ready {
|
|
||||||
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
|
|
||||||
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
|
||||||
} else {
|
|
||||||
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
|
||||||
}
|
|
||||||
// isStarting will be cleared when bootstrap reaches 100%
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private var _dlHandle: UnsafeMutableRawPointer?
|
|
||||||
|
|
||||||
private func frameworkBinaryURL() -> URL? {
|
|
||||||
// Try common embedded locations for the framework binary name
|
|
||||||
let candidates = [
|
|
||||||
"tor-nolzma.framework/tor-nolzma",
|
|
||||||
"Tor.framework/Tor",
|
|
||||||
]
|
|
||||||
if let base = Bundle.main.privateFrameworksURL {
|
|
||||||
for rel in candidates {
|
|
||||||
let url = base.appendingPathComponent(rel)
|
|
||||||
if FileManager.default.fileExists(atPath: url.path) { return url }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For macOS apps, also try Contents/Frameworks explicitly
|
|
||||||
#if os(macOS)
|
|
||||||
if let appURL = Bundle.main.bundleURL as URL?,
|
|
||||||
let frameworksURL = Optional(appURL.appendingPathComponent("Contents/Frameworks", isDirectory: true)) {
|
|
||||||
for rel in candidates {
|
|
||||||
let url = frameworksURL.appendingPathComponent(rel)
|
|
||||||
if FileManager.default.fileExists(atPath: url.path) { return url }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Static-link path (no module import)
|
|
||||||
private func startTorViaLinkedSymbol() -> Bool {
|
|
||||||
// Attempt to start tor_run_main directly (statically linked). If the
|
|
||||||
// symbol is not present at link-time, builds will fail — which is
|
|
||||||
// expected when the xcframework is absent.
|
|
||||||
var argv: [String] = ["tor"]
|
|
||||||
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
|
|
||||||
|
|
||||||
SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
|
|
||||||
let argc = Int32(argv.count)
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
// Build stable C argv in this thread
|
|
||||||
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
|
||||||
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
|
|
||||||
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
|
|
||||||
cArgv[cStrings.count] = nil
|
|
||||||
|
|
||||||
_ = tor_main_c(argc, cArgv)
|
|
||||||
|
|
||||||
// If tor_main ever returns, free memory
|
|
||||||
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
|
|
||||||
cArgv.deallocate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start control monitor early
|
|
||||||
startControlMonitorIfNeeded()
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let ready = await self.waitForSocksReady(timeout: 60.0)
|
|
||||||
await MainActor.run {
|
|
||||||
self.socksReady = ready
|
|
||||||
if ready {
|
|
||||||
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
|
|
||||||
} else {
|
|
||||||
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
|
|
||||||
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
|
|
||||||
}
|
|
||||||
// isStarting will be cleared when bootstrap reaches 100%
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ControlPort monitoring (bootstrap progress)
|
// MARK: - ControlPort monitoring (bootstrap progress)
|
||||||
private func startControlMonitorIfNeeded() {
|
private func startControlMonitorIfNeeded() {
|
||||||
guard !controlMonitorStarted else { return }
|
guard !controlMonitorStarted else { return }
|
||||||
@@ -440,10 +308,6 @@ public final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlMonitorLoop() async {}
|
|
||||||
|
|
||||||
private func tryControlSessionOnce() async -> Bool { false }
|
|
||||||
|
|
||||||
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
|
||||||
private func bootstrapPollLoop() async {
|
private func bootstrapPollLoop() async {
|
||||||
let deadline = Date().addingTimeInterval(75)
|
let deadline = Date().addingTimeInterval(75)
|
||||||
|
|||||||
Reference in New Issue
Block a user