mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 07:25:20 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7040d9ecb0 | ||
|
|
88bafb41cc | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
b81ae0b4c0 | ||
|
|
b6d42261d0 |
@@ -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
|
||||||
|
|||||||
@@ -33,13 +33,32 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2149,19 +2149,6 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
|
|
||||||
// Fire-and-forget: Simple send without complex fallback logic
|
|
||||||
guard peripheral.state == .connected else { return }
|
|
||||||
|
|
||||||
let peripheralUUID = peripheral.identifier.uuidString
|
|
||||||
guard let state = peripherals[peripheralUUID],
|
|
||||||
let characteristic = state.characteristic else { return }
|
|
||||||
|
|
||||||
// Fire-and-forget principle: always use .withoutResponse for speed
|
|
||||||
// CoreBluetooth will handle fragmentation at L2CAP layer
|
|
||||||
writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Fragmentation (Required for messages > BLE MTU)
|
// MARK: Fragmentation (Required for messages > BLE MTU)
|
||||||
|
|
||||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) {
|
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) {
|
||||||
@@ -2652,18 +2639,20 @@ extension BLEService {
|
|||||||
|
|
||||||
var accepted = false
|
var accepted = false
|
||||||
var senderNickname: String = ""
|
var senderNickname: String = ""
|
||||||
|
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
||||||
|
let peersSnapshot = collectionsQueue.sync { peers }
|
||||||
|
|
||||||
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
|
||||||
if peerID == myPeerID {
|
if peerID == myPeerID {
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = myNickname
|
senderNickname = myNickname
|
||||||
}
|
}
|
||||||
else if let info = peers[peerID], info.isVerifiedNickname {
|
else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
|
||||||
// Known verified peer path
|
// Known verified peer path
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = info.nickname
|
senderNickname = info.nickname
|
||||||
// Handle nickname collisions
|
// Handle nickname collisions
|
||||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
||||||
if hasCollision {
|
if hasCollision {
|
||||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||||
}
|
}
|
||||||
@@ -2846,17 +2835,6 @@ extension BLEService {
|
|||||||
|
|
||||||
// MARK: Helper Functions
|
// MARK: Helper Functions
|
||||||
|
|
||||||
private func sendLeave() {
|
|
||||||
SecureLogger.debug("👋 Sending leave announcement", category: .session)
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: MessageType.leave.rawValue,
|
|
||||||
ttl: messageTTL,
|
|
||||||
senderID: myPeerID,
|
|
||||||
payload: Data(myNickname.utf8)
|
|
||||||
)
|
|
||||||
broadcastPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -197,19 +197,36 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Persistent recent content map (LRU) to speed near-duplicate checks
|
// Persistent recent content map (LRU) to speed near-duplicate checks
|
||||||
private var contentLRUMap: [String: Date] = [:]
|
private var contentLRUMap: [String: Date] = [:]
|
||||||
private var contentLRUOrder: [String] = []
|
private var contentLRUOrder: [String] = []
|
||||||
|
private var contentLRUHead = 0
|
||||||
private let contentLRUCap = TransportConfig.contentLRUCap
|
private let contentLRUCap = TransportConfig.contentLRUCap
|
||||||
private func recordContentKey(_ key: String, timestamp: Date) {
|
private func recordContentKey(_ key: String, timestamp: Date) {
|
||||||
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
|
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
|
||||||
contentLRUMap[key] = timestamp
|
contentLRUMap[key] = timestamp
|
||||||
if contentLRUOrder.count > contentLRUCap {
|
trimContentLRUIfNeeded()
|
||||||
let overflow = contentLRUOrder.count - contentLRUCap
|
}
|
||||||
|
|
||||||
|
private func trimContentLRUIfNeeded() {
|
||||||
|
let activeCount = contentLRUOrder.count - contentLRUHead
|
||||||
|
guard activeCount > contentLRUCap else { return }
|
||||||
|
|
||||||
|
let overflow = activeCount - contentLRUCap
|
||||||
for _ in 0..<overflow {
|
for _ in 0..<overflow {
|
||||||
if let victim = contentLRUOrder.first {
|
guard let victim = popOldestContentKey() else { break }
|
||||||
contentLRUOrder.removeFirst()
|
|
||||||
contentLRUMap.removeValue(forKey: victim)
|
contentLRUMap.removeValue(forKey: victim)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func popOldestContentKey() -> String? {
|
||||||
|
guard contentLRUHead < contentLRUOrder.count else { return nil }
|
||||||
|
let victim = contentLRUOrder[contentLRUHead]
|
||||||
|
contentLRUHead += 1
|
||||||
|
|
||||||
|
// Periodically compact the backing storage to avoid unbounded growth.
|
||||||
|
if contentLRUHead >= 32 && contentLRUHead * 2 >= contentLRUOrder.count {
|
||||||
|
contentLRUOrder.removeFirst(contentLRUHead)
|
||||||
|
contentLRUHead = 0
|
||||||
}
|
}
|
||||||
|
return victim
|
||||||
}
|
}
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@@ -348,6 +365,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// PeerManager replaced by UnifiedPeerService
|
// PeerManager replaced by UnifiedPeerService
|
||||||
private var processedNostrEvents = Set<String>() // Simple deduplication
|
private var processedNostrEvents = Set<String>() // Simple deduplication
|
||||||
private var processedNostrEventOrder: [String] = []
|
private var processedNostrEventOrder: [String] = []
|
||||||
|
private var processedNostrEventHead = 0
|
||||||
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
|
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
@@ -2164,29 +2182,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return "anon#\(suffix)"
|
return "anon#\(suffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: display name for current active channel (for notifications)
|
|
||||||
private func activeChannelDisplayName() -> String {
|
|
||||||
switch activeChannel {
|
|
||||||
case .mesh:
|
|
||||||
return "#mesh"
|
|
||||||
case .location(let ch):
|
|
||||||
return "#\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedup helper with small memory cap
|
// Dedup helper with small memory cap
|
||||||
private func recordProcessedEvent(_ id: String) {
|
private func recordProcessedEvent(_ id: String) {
|
||||||
processedNostrEvents.insert(id)
|
processedNostrEvents.insert(id)
|
||||||
processedNostrEventOrder.append(id)
|
processedNostrEventOrder.append(id)
|
||||||
if processedNostrEventOrder.count > maxProcessedNostrEvents {
|
trimProcessedNostrEventsIfNeeded()
|
||||||
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
|
}
|
||||||
|
|
||||||
|
private func trimProcessedNostrEventsIfNeeded() {
|
||||||
|
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
|
||||||
|
guard activeCount > maxProcessedNostrEvents else { return }
|
||||||
|
|
||||||
|
let overflow = activeCount - maxProcessedNostrEvents
|
||||||
for _ in 0..<overflow {
|
for _ in 0..<overflow {
|
||||||
if let old = processedNostrEventOrder.first {
|
guard let old = popOldestProcessedEvent() else { break }
|
||||||
processedNostrEventOrder.removeFirst()
|
|
||||||
processedNostrEvents.remove(old)
|
processedNostrEvents.remove(old)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func popOldestProcessedEvent() -> String? {
|
||||||
|
guard processedNostrEventHead < processedNostrEventOrder.count else { return nil }
|
||||||
|
let value = processedNostrEventOrder[processedNostrEventHead]
|
||||||
|
processedNostrEventHead += 1
|
||||||
|
|
||||||
|
if processedNostrEventHead >= 32 && processedNostrEventHead * 2 >= processedNostrEventOrder.count {
|
||||||
|
processedNostrEventOrder.removeFirst(processedNostrEventHead)
|
||||||
|
processedNostrEventHead = 0
|
||||||
}
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends an encrypted private message to a specific peer.
|
/// Sends an encrypted private message to a specific peer.
|
||||||
@@ -4836,11 +4859,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
private func parseMentions(from content: String) -> [String] {
|
private func parseMentions(from content: String) -> [String] {
|
||||||
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
||||||
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
let regex = Regexes.mention
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
|
||||||
var mentions: [String] = []
|
var mentions: [String] = []
|
||||||
let peerNicknames = meshService.getPeerNicknames()
|
let peerNicknames = meshService.getPeerNicknames()
|
||||||
@@ -5351,61 +5373,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleNostrAcknowledgment(content: String, from senderPubkey: String) {
|
|
||||||
// Parse ACK format: "ACK:TYPE:MESSAGE_ID"
|
|
||||||
let parts = content.split(separator: ":", maxSplits: 2)
|
|
||||||
guard parts.count >= 3 else {
|
|
||||||
SecureLogger.warning("⚠️ Invalid ACK format: \(content)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let ackType = String(parts[1])
|
|
||||||
let messageId = String(parts[2])
|
|
||||||
|
|
||||||
// Check if we've already processed this ACK
|
|
||||||
let ackKey = "\(messageId):\(ackType):\(senderPubkey)"
|
|
||||||
if processedNostrAcks.contains(ackKey) {
|
|
||||||
// Skip duplicate ACK
|
|
||||||
return
|
|
||||||
}
|
|
||||||
processedNostrAcks.insert(ackKey)
|
|
||||||
|
|
||||||
SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
|
|
||||||
|
|
||||||
// Verify the sender has a valid Noise key
|
|
||||||
guard findNoiseKey(for: senderPubkey) != nil else {
|
|
||||||
// Cannot find Noise key for ACK sender
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and update the message status in ALL private chats (both stable and ephemeral)
|
|
||||||
var messageFound = false
|
|
||||||
for (chatPeerID, messages) in privateChats {
|
|
||||||
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
|
||||||
// Update delivery status based on ACK type
|
|
||||||
switch ackType {
|
|
||||||
case "DELIVERED":
|
|
||||||
privateChats[chatPeerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
|
||||||
case "READ":
|
|
||||||
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
|
||||||
default:
|
|
||||||
SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
messageFound = true
|
|
||||||
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.id.prefix(16))...", category: .session)
|
|
||||||
// Don't break - continue to update in all chats where this message exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if messageFound {
|
|
||||||
objectWillChange.send()
|
|
||||||
} else {
|
|
||||||
SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Base64URL utils
|
// MARK: - Base64URL utils
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+")
|
var str = s.replacingOccurrences(of: "-", with: "+")
|
||||||
@@ -5464,111 +5431,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleNostrMessageFromUnknownSender(
|
|
||||||
messageId: String,
|
|
||||||
content: String,
|
|
||||||
senderPubkey: String,
|
|
||||||
senderNickname: String? = nil,
|
|
||||||
timestamp: Date
|
|
||||||
) {
|
|
||||||
// Check if we already have this message in local storage
|
|
||||||
for (_, messages) in privateChats {
|
|
||||||
if messages.contains(where: { $0.id == messageId }) {
|
|
||||||
return // Skipping duplicate message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've read this message before (in a previous session)
|
|
||||||
let wasReadBefore = sentReadReceipts.contains(messageId)
|
|
||||||
|
|
||||||
// Try to find sender by checking all known peers for nickname matches
|
|
||||||
// This is a fallback when we receive Nostr messages from someone not in favorites
|
|
||||||
|
|
||||||
// For now, create a temporary peer ID based on Nostr pubkey
|
|
||||||
// This allows the message to be displayed even without Noise key mapping
|
|
||||||
let tempPeerID = PeerID(nostr_: senderPubkey)
|
|
||||||
|
|
||||||
// Check if we're viewing this unknown sender's chat
|
|
||||||
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
|
|
||||||
|
|
||||||
// Check if message is recent (less than 30 seconds old)
|
|
||||||
let messageAgeSeconds = Date().timeIntervalSince(timestamp)
|
|
||||||
let isRecentMessage = messageAgeSeconds < 30
|
|
||||||
|
|
||||||
// Determine if we should mark as unread BEFORE adding to chats
|
|
||||||
// During startup phase, only block OLD messages from being marked as unread
|
|
||||||
// Recent messages should always be marked as unread if not previously read
|
|
||||||
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
|
|
||||||
|
|
||||||
// Use provided nickname or try to extract from previous messages
|
|
||||||
var finalSenderNickname = senderNickname ?? "Unknown"
|
|
||||||
|
|
||||||
// If no nickname provided, check if we have any previous messages from this Nostr key
|
|
||||||
if senderNickname == nil {
|
|
||||||
for (_, messages) in privateChats {
|
|
||||||
if let previousMessage = messages.first(where: {
|
|
||||||
$0.senderPeerID == tempPeerID
|
|
||||||
}) {
|
|
||||||
finalSenderNickname = previousMessage.sender
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the message
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageId,
|
|
||||||
sender: finalSenderNickname,
|
|
||||||
content: content,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: nickname,
|
|
||||||
senderPeerID: tempPeerID,
|
|
||||||
mentions: nil,
|
|
||||||
deliveryStatus: .delivered(to: nickname, at: Date())
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store in private chats
|
|
||||||
if privateChats[tempPeerID] == nil {
|
|
||||||
privateChats[tempPeerID] = []
|
|
||||||
}
|
|
||||||
privateChats[tempPeerID]?.append(message)
|
|
||||||
|
|
||||||
// For unknown senders (no Noise key), skip sending Nostr ACKs
|
|
||||||
|
|
||||||
// Handle based on read status
|
|
||||||
if wasReadBefore {
|
|
||||||
// Message was read in a previous session - don't mark as unread or notify
|
|
||||||
// Not marking previously-read message as unread
|
|
||||||
} else if isViewingThisChat {
|
|
||||||
// Viewing this chat - mark as read
|
|
||||||
// No read ACKs for unknown senders
|
|
||||||
} else {
|
|
||||||
// Not viewing and not previously read
|
|
||||||
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
|
|
||||||
if shouldMarkAsUnread {
|
|
||||||
unreadPrivateMessages.insert(tempPeerID)
|
|
||||||
|
|
||||||
// Only notify if it's a recent message
|
|
||||||
if isRecentMessage {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
|
||||||
from: finalSenderNickname,
|
|
||||||
message: content,
|
|
||||||
peerID: tempPeerID.id
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Not notifying for old message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Not notifying for old message
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func findNoiseKey(for nostrPubkey: String) -> Data? {
|
private func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
// Convert hex to npub if needed for comparison
|
// Convert hex to npub if needed for comparison
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ struct ContentView: View {
|
|||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
@ObservedObject private var notesCounter = LocationNotesCounter.shared
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@State private var textFieldSelection: NSRange? = nil
|
@State private var textFieldSelection: NSRange? = nil
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@@ -51,7 +50,6 @@ struct ContentView: View {
|
|||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
@State private var sheetNotesCount: Int = 0
|
|
||||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
||||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
||||||
@@ -1287,10 +1285,9 @@ struct ContentView: View {
|
|||||||
showLocationNotes = true
|
showLocationNotes = true
|
||||||
}) {
|
}) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
let hasNotes = (notesCounter.count ?? 0) > 0
|
Image(systemName: "note.text")
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
.foregroundColor(Color.orange.opacity(0.8))
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
@@ -1392,7 +1389,7 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
Group {
|
Group {
|
||||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
LocationNotesView(geohash: gh)
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -1449,7 +1446,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
@@ -1457,16 +1453,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.selectedChannel) { _ in
|
.onChange(of: locationManager.selectedChannel) { _ in
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
LocationChannelManager.shared.refreshChannels()
|
LocationChannelManager.shared.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
|
|
||||||
.onChange(of: locationManager.permissionState) { _ in
|
.onChange(of: locationManager.permissionState) { _ in
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
@@ -1482,31 +1475,3 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notes Counter Subscription Helper
|
|
||||||
extension ContentView {
|
|
||||||
private func updateNotesCounterSubscription() {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
// Ensure we have a fresh one-shot location fix so building geohash is current
|
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
LocationChannelManager.shared.refreshChannels()
|
|
||||||
}
|
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
|
||||||
LocationNotesCounter.shared.subscribe(geohash: building)
|
|
||||||
} else {
|
|
||||||
// Keep existing subscription if we had one to avoid flicker
|
|
||||||
// Only cancel if we have no known geohash
|
|
||||||
if LocationNotesCounter.shared.geohash == nil {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
case .location:
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -141,7 +141,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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