mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
[codex] Refactor BLE outbound scheduling and Noise queues (#1306)
* Refactor BLE transport event handling * Make image output paths unique * Keep queued Nostr read receipts alive * Refine BLE ingress fanout * Rediscover BLE service after invalidation * Extract BLE notification retry buffer * Extract BLE inbound write buffer * Extract BLE fragment assembly buffer * Tidy secure log handling from device run * Extract BLE outbound fragment scheduler * Harden app CI media tests * Redact BLE message content from logs * Extract BLE Noise session queues * Fix BLE read receipt UI updates * Allow self-authored RSR ingress replies * Harden read receipt queue test timing * Extract BLE outbound policy and incoming file storage * Avoid duplicate BLE link snapshots during send * Canonicalize Nostr relay URLs --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
|
||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
func save(
|
||||
data: Data,
|
||||
preferredName: String?,
|
||||
subdirectory: String,
|
||||
fallbackExtension: String?,
|
||||
defaultPrefix: String
|
||||
) -> URL? {
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
let sanitized = sanitizedFileName(
|
||||
preferredName,
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
fallbackExtension: fallbackExtension
|
||||
)
|
||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
return destination
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||
]
|
||||
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||
|
||||
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { continue }
|
||||
|
||||
for fileURL in contents {
|
||||
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
||||
let size = attrs.fileSize,
|
||||
let modified = attrs.contentModificationDate else { continue }
|
||||
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
||||
}
|
||||
}
|
||||
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
let needToFree = currentUsage - targetUsage
|
||||
var freedSpace: Int64 = 0
|
||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||
guard freedSpace < needToFree else { break }
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
if freedSpace > 0 {
|
||||
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
var candidate = (name ?? "")
|
||||
.replacingOccurrences(of: "\0", with: "")
|
||||
.precomposedStringWithCanonicalMapping
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "\\", with: "_")
|
||||
|
||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
||||
if candidate.isEmpty { candidate = defaultName }
|
||||
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
||||
|
||||
if candidate.count > 120 {
|
||||
let ext = (candidate as NSString).pathExtension
|
||||
let base = (candidate as NSString).deletingPathExtension
|
||||
candidate = ext.isEmpty
|
||||
? String(candidate.prefix(120))
|
||||
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
||||
}
|
||||
|
||||
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
||||
candidate += ".\(fallbackExtension)"
|
||||
}
|
||||
|
||||
return candidate.isEmpty ? defaultName : candidate
|
||||
}
|
||||
|
||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||
let directoryPath = directory.standardizedFileURL.path
|
||||
func isInsideDirectory(_ url: URL) -> Bool {
|
||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||
}
|
||||
|
||||
var candidate = directory.appendingPathComponent(fileName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
for counter in 1..<100 {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
candidate = directory.appendingPathComponent(newName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
||||
}
|
||||
|
||||
private static func timestampString(from date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
enum BLENoisePayloadFactory {
|
||||
static func privateMessage(content: String, messageID: String) -> Data? {
|
||||
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return typedPayload(.privateMessage, payload: payload)
|
||||
}
|
||||
|
||||
static func readReceipt(originalMessageID: String) -> Data {
|
||||
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
|
||||
}
|
||||
|
||||
static func delivered(messageID: String) -> Data {
|
||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||
}
|
||||
|
||||
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||
var typed = Data([type.rawValue])
|
||||
typed.append(payload)
|
||||
return typed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEPendingPrivateMessage: Equatable {
|
||||
let content: String
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct BLENoiseSessionQueues {
|
||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
privateMessagesByPeerID.removeAll()
|
||||
typedPayloadsByPeerID.removeAll()
|
||||
}
|
||||
|
||||
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
|
||||
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
|
||||
}
|
||||
|
||||
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
|
||||
let messages = privateMessagesByPeerID[peerID] ?? []
|
||||
privateMessagesByPeerID.removeValue(forKey: peerID)
|
||||
return messages
|
||||
}
|
||||
|
||||
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
|
||||
guard !messages.isEmpty else { return }
|
||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||
}
|
||||
|
||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||
}
|
||||
|
||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||
return payloads
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEOutboundFragmentTransferRequest {
|
||||
let packet: BitchatPacket
|
||||
let pad: Bool
|
||||
let maxChunk: Int?
|
||||
let directedPeer: PeerID?
|
||||
let transferId: String?
|
||||
|
||||
var resolvedTransferId: String? {
|
||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||
return transferId ?? packet.payload.sha256Hex()
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEOutboundFragmentTransferScheduler {
|
||||
enum QueuePosition {
|
||||
case front
|
||||
case back
|
||||
}
|
||||
|
||||
enum SubmitResult {
|
||||
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
||||
}
|
||||
|
||||
enum CancelResult {
|
||||
case active(transferId: String, workItems: [DispatchWorkItem])
|
||||
case pending(transferId: String)
|
||||
case missing
|
||||
}
|
||||
|
||||
enum SentResult: Equatable {
|
||||
case progress(sentFragments: Int, totalFragments: Int)
|
||||
case complete(sentFragments: Int, totalFragments: Int)
|
||||
case missing
|
||||
}
|
||||
|
||||
private struct ActiveTransferState {
|
||||
let totalFragments: Int
|
||||
var sentFragments: Int
|
||||
var workItems: [DispatchWorkItem]
|
||||
}
|
||||
|
||||
private var activeTransfers: [String: ActiveTransferState] = [:]
|
||||
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
||||
|
||||
var activeCount: Int {
|
||||
activeTransfers.count
|
||||
}
|
||||
|
||||
var pendingCount: Int {
|
||||
pendingTransfers.count
|
||||
}
|
||||
|
||||
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
|
||||
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
|
||||
activeTransfers.removeAll()
|
||||
pendingTransfers.removeAll()
|
||||
return active
|
||||
}
|
||||
|
||||
mutating func submit(
|
||||
_ request: BLEOutboundFragmentTransferRequest,
|
||||
maxConcurrentTransfers: Int
|
||||
) -> SubmitResult {
|
||||
guard let transferId = request.resolvedTransferId else {
|
||||
return .start(request: request, reservedTransferId: nil)
|
||||
}
|
||||
|
||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||
pendingTransfers.append(request)
|
||||
return .queued(request: request, transferId: transferId, position: .back)
|
||||
}
|
||||
|
||||
guard activeTransfers[transferId] == nil else {
|
||||
pendingTransfers.insert(request, at: 0)
|
||||
return .queued(request: request, transferId: transferId, position: .front)
|
||||
}
|
||||
|
||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||
return .start(request: request, reservedTransferId: transferId)
|
||||
}
|
||||
|
||||
mutating func activateReservedTransfer(
|
||||
id transferId: String,
|
||||
totalFragments: Int,
|
||||
workItems: [DispatchWorkItem]
|
||||
) -> Bool {
|
||||
guard activeTransfers[transferId] != nil else { return false }
|
||||
activeTransfers[transferId] = ActiveTransferState(
|
||||
totalFragments: totalFragments,
|
||||
sentFragments: 0,
|
||||
workItems: workItems
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
|
||||
guard var state = activeTransfers[transferId] else { return false }
|
||||
state.workItems = workItems
|
||||
activeTransfers[transferId] = state
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
|
||||
activeTransfers.removeValue(forKey: transferId)?.workItems
|
||||
}
|
||||
|
||||
func isActive(_ transferId: String) -> Bool {
|
||||
activeTransfers[transferId] != nil
|
||||
}
|
||||
|
||||
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
|
||||
if let active = activeTransfers.removeValue(forKey: transferId) {
|
||||
return .active(transferId: transferId, workItems: active.workItems)
|
||||
}
|
||||
|
||||
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
|
||||
pendingTransfers.remove(at: pendingIndex)
|
||||
return .pending(transferId: transferId)
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
|
||||
mutating func markFragmentSent(transferId: String) -> SentResult {
|
||||
guard var state = activeTransfers[transferId] else { return .missing }
|
||||
|
||||
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
|
||||
let isComplete = state.sentFragments >= state.totalFragments
|
||||
|
||||
if isComplete {
|
||||
activeTransfers.removeValue(forKey: transferId)
|
||||
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||
}
|
||||
|
||||
activeTransfers[transferId] = state
|
||||
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||
}
|
||||
|
||||
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
|
||||
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
|
||||
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
|
||||
|
||||
var results: [SubmitResult] = []
|
||||
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
|
||||
|
||||
while availableSlots > 0, !pendingTransfers.isEmpty {
|
||||
let request = pendingTransfers.removeFirst()
|
||||
availableSlots -= 1
|
||||
|
||||
guard let transferId = request.resolvedTransferId else {
|
||||
results.append(.start(request: request, reservedTransferId: nil))
|
||||
continue
|
||||
}
|
||||
|
||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||
pendingTransfers.insert(request, at: 0)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
break
|
||||
}
|
||||
|
||||
guard activeTransfers[transferId] == nil else {
|
||||
blockedFront.append(request)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
continue
|
||||
}
|
||||
|
||||
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||
results.append(.start(request: request, reservedTransferId: transferId))
|
||||
}
|
||||
|
||||
if !blockedFront.isEmpty {
|
||||
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum BLEOutboundPacketPolicy {
|
||||
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
|
||||
|
||||
static func messageID(for packet: BitchatPacket) -> String {
|
||||
BLEIngressLinkRegistry.messageID(for: packet)
|
||||
}
|
||||
|
||||
static func padsBLEFrame(for packetType: UInt8) -> Bool {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||
switch messageType {
|
||||
case .fragment:
|
||||
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||
case .fileTransfer:
|
||||
return .fileTransfer
|
||||
default:
|
||||
return .high
|
||||
}
|
||||
}
|
||||
|
||||
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
|
||||
max(64, limit - fragmentFrameOverhead)
|
||||
}
|
||||
|
||||
private static func fragmentTotalCount(from payload: Data) -> Int {
|
||||
guard payload.count >= 12 else { return Int(UInt16.max) }
|
||||
let totalHigh = Int(payload[10])
|
||||
let totalLow = Int(payload[11])
|
||||
let total = (totalHigh << 8) | totalLow
|
||||
return max(total, 1)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user