Compare commits

...
6 Commits
Author SHA1 Message Date
7040d9ecb0 Add plus-minus feature to location notes (± 1 grid) (#821)
Expands location notes coverage to include 8 neighboring geohash cells,
creating a 3×3 grid around the user's current building-level location.

Changes:
- Add Geohash.neighbors() method to calculate 8 surrounding cells
- Update LocationNotesManager to subscribe to center + neighbors (9 cells total)
- Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions
- Display '± 1' indicator in LocationNotesView header

Coverage:
- Single cell: ~38m × 19m
- 3×3 grid: ~114m × 57m total area
- Better discovery of nearby activity

Behavior:
- Subscribe: Fetches notes from all 9 cells (single efficient subscription)
- Post: Still uses center geohash only (correct privacy-preserving behavior)
- UI: Shows 'geohash ± 1' to indicate expanded coverage

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:44:06 +02:00
88bafb41cc Remove LocationNotesCounter for privacy-first approach (#820)
Simplifies geonotes architecture by eliminating all background subscriptions:

- Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines)
- Remove background subscription system entirely
- Icon is now constant darker orange (indicates availability, not state)
- Subscription ONLY happens when user explicitly opens the sheet
- Total: ~220 lines of code removed

Privacy Benefits:
- Zero network traffic until user action
- No location leaking to relays via background polling
- User must explicitly opt-in to discover notes

The icon (note.text in orange) simply indicates the feature is available
when location permission is granted. Notes are only fetched when the
sheet is opened, prioritizing privacy over convenience.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:06:31 +02:00
jackandGitHub fb43a8b0f5 Reuse cached mention regex in parseMentions (#812) 2025-10-17 23:02:58 +02:00
jackandGitHub eb35608fa1 Remove unused helpers and add cross-platform logging fallbacks (#811) 2025-10-17 22:58:56 +02:00
RedThoroughbredandGitHub b81ae0b4c0 fix: improve Xcode detection in Justfile (#814)
- Add check for full Xcode vs command line tools only
- Provide clearer error messages with setup instructions
- Verify Xcode is installed and properly configured
- Add better validation for development environment

Fixes issue where 'just run' fails with cryptic error when only
command line tools are installed. Now gives clear guidance on
installing full Xcode and configuring xcode-select properly.

Resolves #760
2025-10-17 21:37:51 +02:00
jackandGitHub b6d42261d0 Guard peer collision checks with snapshot (#810) 2025-10-15 16:12:16 +02:00
16 changed files with 229 additions and 627 deletions
+5 -2
View File
@@ -14,8 +14,11 @@ default:
# Check prerequisites
check:
@echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@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"
# Backup original files
+25 -6
View File
@@ -33,13 +33,32 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] }
let sorted = entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.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()
}
.prefix(count)
return sorted.map { "wss://\($0.host)" }
}
return best.map { "wss://\($0.entry.host)" }
}
// MARK: - Remote Fetch
+10
View File
@@ -906,6 +906,16 @@ struct NostrFilter: Encodable {
filter.limit = limit
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
+53
View File
@@ -119,4 +119,57 @@ enum Geohash {
}
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)
}
}
}
+4 -26
View File
@@ -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)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) {
@@ -2652,18 +2639,20 @@ extension BLEService {
var accepted = false
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 peerID == myPeerID {
accepted = true
senderNickname = myNickname
}
else if let info = peers[peerID], info.isVerifiedNickname {
else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
// Known verified peer path
accepted = true
senderNickname = info.nickname
// 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 {
senderNickname += "#" + String(peerID.id.prefix(4))
}
@@ -2846,17 +2835,6 @@ extension BLEService {
// 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) {
// Throttle announces to prevent flooding
let now = Date()
+1 -19
View File
@@ -65,14 +65,11 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default:
return .error(message: "unknown command: \(cmd)")
}
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -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)
}
}
-28
View File
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
private let service = 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
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
-104
View File
@@ -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
}
}
+12 -4
View File
@@ -163,14 +163,22 @@ final class LocationNotesManager: ObservableObject {
subscriptionID = subID
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
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
// Ensure matching tag - accept any of our 9 geohashes
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 }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
+51 -189
View File
@@ -197,20 +197,37 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private var contentLRUHead = 0
private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
if contentLRUOrder.count > contentLRUCap {
let overflow = contentLRUOrder.count - contentLRUCap
for _ in 0..<overflow {
if let victim = contentLRUOrder.first {
contentLRUOrder.removeFirst()
contentLRUMap.removeValue(forKey: victim)
}
}
trimContentLRUIfNeeded()
}
private func trimContentLRUIfNeeded() {
let activeCount = contentLRUOrder.count - contentLRUHead
guard activeCount > contentLRUCap else { return }
let overflow = activeCount - contentLRUCap
for _ in 0..<overflow {
guard let victim = popOldestContentKey() else { break }
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
@Published var messages: [BitchatMessage] = []
@@ -348,6 +365,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = []
private var processedNostrEventHead = 0
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard
private let keychain: KeychainManagerProtocol
@@ -2164,30 +2182,35 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
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
private func recordProcessedEvent(_ id: String) {
processedNostrEvents.insert(id)
processedNostrEventOrder.append(id)
if processedNostrEventOrder.count > maxProcessedNostrEvents {
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
for _ in 0..<overflow {
if let old = processedNostrEventOrder.first {
processedNostrEventOrder.removeFirst()
processedNostrEvents.remove(old)
}
}
trimProcessedNostrEventsIfNeeded()
}
private func trimProcessedNostrEventsIfNeeded() {
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
guard activeCount > maxProcessedNostrEvents else { return }
let overflow = activeCount - maxProcessedNostrEvents
for _ in 0..<overflow {
guard let old = popOldestProcessedEvent() else { break }
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.
/// - Parameters:
@@ -4836,11 +4859,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func parseMentions(from content: String) -> [String] {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let regex = Regexes.mention
let nsContent = content as NSString
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] = []
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
private static func base64URLDecode(_ s: String) -> Data? {
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
private func findNoiseKey(for nostrPubkey: String) -> Data? {
// Convert hex to npub if needed for comparison
+3 -38
View File
@@ -25,7 +25,6 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var notesCounter = LocationNotesCounter.shared
@State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@@ -51,7 +50,6 @@ struct ContentView: View {
@State private var expandedMessageIDs: Set<String> = []
@State private var showLocationNotes = false
@State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
@@ -1287,10 +1285,9 @@ struct ContentView: View {
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
let hasNotes = (notesCounter.count ?? 0) > 0
Image(systemName: "long.text.page.and.pencil")
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(hasNotes ? textColor : Color.gray)
.foregroundColor(Color.orange.opacity(0.8))
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
@@ -1392,7 +1389,7 @@ struct ContentView: View {
}) {
Group {
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)
} else {
VStack(spacing: 12) {
@@ -1449,7 +1446,6 @@ struct ContentView: View {
}
}
.onAppear {
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
@@ -1457,16 +1453,13 @@ struct ContentView: View {
}
}
.onChange(of: locationManager.selectedChannel) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
.onChange(of: locationManager.permissionState) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
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()
}
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ struct LocationNotesView: View {
String(
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
locale: .current,
geohash, count
"\(geohash) ± 1", count
)
}
@@ -91,61 +91,3 @@ struct LocationNotesManagerTests {
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,11 +6,13 @@
// For more information, see <https://unlicense.org>
//
#if canImport(os.log)
import os.log
#endif
public extension OSLog {
private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise")
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
@@ -7,7 +7,53 @@
//
import Foundation
#if canImport(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
/// Provides safe logging that filters sensitive data and security events
+15 -151
View File
@@ -1,11 +1,23 @@
import BitLogger
import Foundation
#if canImport(Network)
import Network
#endif
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
// Declare C entrypoint for Tor when statically linked from an xcframework.
@_silgen_name("tor_main")
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
#if !canImport(Network)
private final class NWPathMonitor {
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)
@_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)
private func startControlMonitorIfNeeded() {
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.
private func bootstrapPollLoop() async {
let deadline = Date().addingTimeInterval(75)