diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index e606506f..1e81e87d 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -8,6 +8,9 @@ import SwiftUI import UserNotifications +#if DEBUG +import BitLogger +#endif @main struct BitchatApp: App { @@ -43,6 +46,11 @@ struct BitchatApp: App { .onAppear { appDelegate.runtime = runtime runtime.start() + #if DEBUG + // Arm the opt-in UDP log sink from persisted config (no-op + // until a collector host is set in the App Info sheet). + LogNetworkSink.shared.reloadConfiguration() + #endif } .onOpenURL { url in runtime.handleOpenURL(url) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3f03c2b6..cc342856 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2703,7 +2703,7 @@ extension BLEService { private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { let now = Date() - let route = BLESourceRouteOriginationPolicy.route( + let decision = BLESourceRouteOriginationPolicy.decide( for: packet, to: recipient, localPeerIDData: myPeerIDData, @@ -2715,7 +2715,19 @@ extension BLEService { }, computeRoute: { self.computeRoute(to: $0) } ) - guard let route else { return packet } + let route: [Data] + switch decision { + case .flood(let reason): + // Only log the interesting directed cases; a plain broadcast keeps + // flood by design and would spam the origination path. + if reason != .broadcast { + SecureLogger.info("[ROUTE] flood to \(recipient.id.prefix(8))… (\(reason.rawValue))", category: .mesh) + } + return packet + case .route(let hops): + SecureLogger.info("[ROUTE] source-route to \(recipient.id.prefix(8))… via \(hops.count) hop(s)", category: .mesh) + route = hops + } // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( type: packet.type, @@ -2805,7 +2817,7 @@ extension BLEService { private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) { guard packet.recipientID == myPeerIDData else { return } guard let ping = MeshPingPayload.decode(packet.payload) else { - SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session) + SecureLogger.debug("[PING] malformed ping via \(linkPeerID.id.prefix(8))…", category: .mesh) return } let allowed = collectionsQueue.sync(flags: .barrier) { @@ -2813,7 +2825,7 @@ extension BLEService { } guard allowed else { if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") { - SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security) + SecureLogger.warning("[PING] rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .mesh) } return } @@ -2847,6 +2859,7 @@ extension BLEService { rttMs: max(0, rttMs), hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl) ) + SecureLogger.info("[PING] pong from \(peerID.id.prefix(8))… rtt=\(result.rttMs)ms hops=\(result.hops)", category: .mesh) Task { @MainActor in pending.completion(result) } } @@ -2855,11 +2868,15 @@ extension BLEService { func computeMeshPath(to peerID: PeerID) -> [PeerID]? { refreshLocalTopology() if let route = computeRoute(to: peerID) { - return route.compactMap { PeerID(routingData: $0) } + let path = route.compactMap { PeerID(routingData: $0) } + SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… via \(path.count) hop(s)", category: .mesh) + return path } // Confirmed claims can lag a brand-new link (the peer's next announce // hasn't arrived yet); a live direct connection is still a known path. - return isPeerConnected(peerID) ? [] : nil + let direct = isPeerConnected(peerID) + SecureLogger.info("[TRACE] path to \(peerID.id.prefix(8))… \(direct ? "direct (0 hops)" : "no known path")", category: .mesh) + return direct ? [] : nil } /// Mesh graph for the topology map. Edges are advisory: announces cap @@ -3005,9 +3022,11 @@ extension BLEService { if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) prekeyID = prekey.id + SecureLogger.info("[PREKEY] seal prekey (id=\(prekey.id)) id=\(messageID.prefix(8))…", category: .session) } else { sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) prekeyID = nil + SecureLogger.info("[PREKEY] seal static (no bundle) id=\(messageID.prefix(8))…", category: .session) } let envelope = CourierEnvelope( recipientTag: CourierEnvelope.recipientTag( @@ -3101,6 +3120,7 @@ extension BLEService { (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) if opened.consumedPrekey { let replenished = noiseService.replenishPrekeysIfNeeded() + SecureLogger.info("[PREKEY] consume id=\(prekeyID), re-gossip bundle (replenished=\(replenished))", category: .session) sendPrekeyBundle(force: replenished) } } else { @@ -3332,7 +3352,7 @@ extension BLEService { return nil } guard let signingKey else { - SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security) + SecureLogger.debug("[PREKEY] bundle defer (no bound signing key) owner \(owner.id.prefix(8))…", category: .session) return } ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) @@ -3343,11 +3363,13 @@ extension BLEService { private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { - SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security) + SecureLogger.info("[PREKEY] bundle reject (bad signature) owner \(owner.id.prefix(8))…", category: .session) return } if prekeyBundleStore.ingest(bundle) { - SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security) + SecureLogger.info("[PREKEY] bundle accept owner \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .session) + } else { + SecureLogger.debug("[PREKEY] bundle reject (not newer/invalid) owner \(owner.id.prefix(8))…", category: .session) } gossipSyncManager?.onPublicPacketSeen(packet) } diff --git a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift index 5cc8bf9c..48d3d173 100644 --- a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift +++ b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift @@ -4,8 +4,27 @@ import Foundation /// Decides whether an outbound directed packet should carry a v2 source /// route. Pure gating logic so BLEService's hot send path stays a thin wire. enum BLESourceRouteOriginationPolicy { - /// Returns the intermediate-hop route to attach, or nil to keep the - /// current flood/direct-write behavior unchanged. + /// Why a packet kept flood/direct-write behavior instead of routing. + /// The `rawValue` doubles as the greppable `[ROUTE]` log reason. + enum FloodReason: String { + case relayedNotOriginator = "not originator" + case broadcast = "broadcast recipient" + case noTTLHeadroom = "link-local ttl" + case recipientDirect = "recipient direct" + case routeSuppressed = "route suppressed→flood" + case noPath = "no v2 path" + } + + /// The routing decision for an originated packet. + enum Decision: Equatable { + /// Keep flood/direct-write behavior unchanged; carries the reason. + case flood(FloodReason) + /// Originate a v2 source route over these intermediate hops. + case route([Data]) + } + + /// Returns whether to originate a v2 source route (with its hops) or keep + /// flood/direct-write, with the reason for the latter. /// /// Routes are only originated when every gate passes: /// - we authored the packet (relays must not rewrite and re-sign someone @@ -19,22 +38,22 @@ enum BLESourceRouteOriginationPolicy { /// - routing to the recipient is not suppressed by a recent unconfirmed /// routed send, and /// - the topology yields a complete path. - static func route( + static func decide( for packet: BitchatPacket, to recipient: PeerID, localPeerIDData: Data, isRecipientConnected: (PeerID) -> Bool, shouldAttemptRoute: (PeerID) -> Bool, computeRoute: (PeerID) -> [Data]? - ) -> [Data]? { - guard packet.senderID == localPeerIDData else { return nil } + ) -> Decision { + guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) } guard let recipientData = packet.recipientID, recipientData.count == 8, - !recipientData.allSatisfy({ $0 == 0xFF }) else { return nil } - guard packet.ttl > 1 else { return nil } - guard !isRecipientConnected(recipient) else { return nil } - guard shouldAttemptRoute(recipient) else { return nil } - guard let route = computeRoute(recipient), !route.isEmpty else { return nil } - return route + !recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) } + guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) } + guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) } + guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) } + guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) } + return .route(route) } } diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift index e325cb39..c7bbb1ba 100644 --- a/bitchat/Services/Gateway/GatewayService.swift +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -152,7 +152,7 @@ final class GatewayService: ObservableObject { pendingDownlinks.removeAll() uplinkDepositTimes.removeAll() } - SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session) + SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway) onEnabledChanged?(enabled) } @@ -163,7 +163,7 @@ final class GatewayService: ObservableObject { /// for broadcasts (downlink rebroadcasts from a gateway). func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { guard let carrier = NostrCarrierPacket.decode(payload) else { - SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session) + SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway) return } switch carrier.direction { @@ -186,7 +186,7 @@ final class GatewayService: ObservableObject { // age) — no crypto — so junk and stale replays are dropped before we // ever pay for a MainActor Schnorr verify. guard let event = structurallyValidEvent(from: carrier) else { - SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) + SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway) return } // Dedup by the carried event ID BEFORE verification. Loop rule 1: a @@ -197,18 +197,19 @@ final class GatewayService: ObservableObject { guard !meshBroadcastEventIDs.contains(event.id), !publishedEventIDs.contains(event.id), !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway) return } // Consume the per-depositor rate token BEFORE the expensive verify so // a flood of distinct forged/junk deposits is bounded by cheap work, // not by main-actor Schnorr verifications. guard allowUplinkDeposit(from: depositor) else { - SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session) + SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway) return } // Only now pay for cryptographic verification; receivers verify again. guard event.isValidSignature() else { - SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) + SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway) return } @@ -218,6 +219,9 @@ final class GatewayService: ObservableObject { accepted = true } else { accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + if accepted { + SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway) + } } // Only render on our own timeline what we actually accepted for @@ -243,7 +247,7 @@ final class GatewayService: ObservableObject { private func publish(_ event: NostrEvent, geohash: String) { publishedEventIDs.insert(event.id) publishToRelays?(event, geohash) - SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) + SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway) } /// Returns true when the item was actually stored for later publish. @@ -251,7 +255,7 @@ final class GatewayService: ObservableObject { private func enqueueUplink(_ item: QueuedUplink) -> Bool { let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { - SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session) + SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway) return false } if queuedUplinks.count >= Limits.maxQueuedUplinks { @@ -294,6 +298,7 @@ final class GatewayService: ObservableObject { // event's own `#g` tag to match the carrier geohash. guard isFresh(event), event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { + SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))…", category: .gateway) return } // Loop rule 1: never rebroadcast mesh-carried events back onto the @@ -305,10 +310,14 @@ final class GatewayService: ObservableObject { guard !meshBroadcastEventIDs.contains(event.id), !rebroadcastEventIDs.contains(event.id), !pendingDownlinks.contains(where: { $0.event.id == event.id }) else { + SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway) return } // Verify before spending BLE airtime; receivers verify again. - guard event.isValidSignature() else { return } + guard event.isValidSignature() else { + SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway) + return + } pendingDownlinks.append((event, geohash)) if pendingDownlinks.count > Limits.maxPendingDownlinks { @@ -316,6 +325,7 @@ final class GatewayService: ObservableObject { // dropped event is not yet in `rebroadcastEventIDs`, so a later // relay redelivery can still carry it. pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway) } drainPendingDownlinks() } @@ -332,6 +342,7 @@ final class GatewayService: ObservableObject { guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), let payload = carrier.encode() else { continue } broadcastToMesh?(payload) + SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway) // Mark-after-send: only now is the relay event definitively // rebroadcast (loop rule 2). rebroadcastEventIDs.insert(event.id) @@ -351,9 +362,11 @@ final class GatewayService: ObservableObject { let oldest = downlinkSendTimes.min() ?? now() let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) downlinkDrainScheduled = true + SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway) let fire: @MainActor () -> Void = { [weak self] in guard let self else { return } self.downlinkDrainScheduled = false + SecureLogger.info("[GW] drain-timer fired", category: .gateway) self.drainPendingDownlinks() } if let scheduleDrainTimer { @@ -408,7 +421,7 @@ final class GatewayService: ObservableObject { return false } guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } - SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session) + SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway) return true } diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 9e7e6eae..1642f618 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -463,6 +463,7 @@ final class NoiseEncryptionService { /// so the caller can re-gossip the shrunken bundle only when it changed. func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { + SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session) throw NoiseEncryptionError.unknownPrekey } let handshake = NoiseHandshakeState( diff --git a/bitchat/Services/WifiBulk/WifiBulkChannel.swift b/bitchat/Services/WifiBulk/WifiBulkChannel.swift index af0b67c5..76327538 100644 --- a/bitchat/Services/WifiBulk/WifiBulkChannel.swift +++ b/bitchat/Services/WifiBulk/WifiBulkChannel.swift @@ -137,7 +137,7 @@ final class WifiBulkSenderSession { do { listener = try NWListener(using: parameters) } catch { - SecureLogger.error("WifiBulk: listener creation failed: \(error)", category: .session) + SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport) return false } listener.service = service @@ -214,7 +214,7 @@ final class WifiBulkSenderSession { guard let self, let connection, !self.finished, self.authenticated == nil else { return false } guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else { // Bonjour-level gatecrasher: no channel key, no service. - SecureLogger.warning("WifiBulk: disconnecting client with invalid auth frame", category: .security) + SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security) self.dropCandidate(connection) return false } @@ -238,6 +238,7 @@ final class WifiBulkSenderSession { candidate.cancel() } candidates.removeAll() + SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport) streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer) } @@ -372,8 +373,10 @@ final class WifiBulkReceiverSession { guard let self else { return } switch state { case .ready: + SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport) self.sendAuthFrameAndReceive() case .failed(let error): + SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport) self.fail("connect failed: \(error)") case .waiting(let error): // .waiting can resolve on its own, but a per-transfer channel diff --git a/bitchat/Services/WifiBulk/WifiBulkTransferService.swift b/bitchat/Services/WifiBulk/WifiBulkTransferService.swift index f4ff408a..d71bad60 100644 --- a/bitchat/Services/WifiBulk/WifiBulkTransferService.swift +++ b/bitchat/Services/WifiBulk/WifiBulkTransferService.swift @@ -172,6 +172,7 @@ final class WifiBulkTransferService { serviceName: serviceName ) guard let offerData = offer.encode() else { + SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport) fallbackToBLE() return } @@ -229,7 +230,7 @@ final class WifiBulkTransferService { return } - SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .session) + SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .transport) environment.progressStart(transferId, session.totalChunks) let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in @@ -271,6 +272,7 @@ final class WifiBulkTransferService { transfer.accepted = true transfer.offerTimeout?.cancel() transfer.offerTimeout = nil + SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport) transfer.session?.activate(key: key) } @@ -284,13 +286,13 @@ final class WifiBulkTransferService { switch outcome { case .completed: - SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session) + SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport) case .fallback: - SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8))… falling back to BLE (\(reason))", category: .session) + SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport) environment.progressReset(transfer.transferId) transfer.fallback() case .cancelled: - SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session) + SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport) environment.progressCancel(transfer.transferId) } } @@ -338,13 +340,13 @@ final class WifiBulkTransferService { let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key) incoming[offer.transferID] = transfer - SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session) + SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport) startBrowsing(for: transfer) let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in guard let self, let transfer else { return } - SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session) + SecureLogger.info("[WIFI] incoming window expired", category: .transport) self.tearDownIncoming(transfer) } transfer.windowTimeout = windowTimeout @@ -352,7 +354,7 @@ final class WifiBulkTransferService { } private func decline(offer: WifiBulkOffer, peerID: PeerID) { - SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session) + SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport) guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return } _ = environment.sendNoisePayload( BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData), @@ -380,7 +382,7 @@ final class WifiBulkTransferService { browser.stateUpdateHandler = { [weak self, weak transfer] state in guard let self, let transfer else { return } if case .failed(let error) = state { - SecureLogger.warning("WifiBulk: browser failed: \(error)", category: .session) + SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport) self.tearDownIncoming(transfer) } } @@ -416,13 +418,13 @@ final class WifiBulkTransferService { } session.onCompleted = { [weak self, weak transfer] payload in guard let self, let transfer else { return } - SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .session) + SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport) self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes) self.tearDownIncoming(transfer) } session.onFailed = { [weak self, weak transfer] reason in guard let self, let transfer else { return } - SecureLogger.info("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session) + SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport) self.tearDownIncoming(transfer) } transfer.session = session diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index af12bc5c..78c0ac21 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -313,7 +313,6 @@ final class GossipSyncManager { private func sendPeriodicSync(for types: SyncTypeFlags) { // Unicast sync to connected peers to allow RSR attribution if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty { - SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync) for peerID in connectedPeers { sendRequestSync(to: peerID, types: types) } @@ -350,7 +349,7 @@ final class GossipSyncManager { private func _requestMissingFragments(_ fragmentIDs: [Data]) { guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } - SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync) + SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh) for peerID in connectedPeers { sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) } @@ -391,7 +390,7 @@ final class GossipSyncManager { // A response can replay the whole store, so bound how often one peer // can trigger a diff pass regardless of how fast it asks. guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { - SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) + SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) return } let requestedTypes = (request.types ?? .publicMessages) @@ -399,6 +398,9 @@ final class GossipSyncManager { // older packets are outside the filter but not missing, and without // the cursor they would be re-sent every round. let since = request.sinceTimestamp + // One concise per-request summary (RSR is already rate-limited per + // peer), never per-packet spam. + var servedCount = 0 // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { @@ -418,6 +420,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -432,6 +435,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -456,6 +460,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -470,6 +475,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -488,6 +494,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -502,6 +509,7 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } @@ -518,9 +526,14 @@ final class GossipSyncManager { toSend.ttl = 0 toSend.isRSR = true // Mark as solicited response delegate?.sendPacket(to: peerID, packet: toSend) + servedCount += 1 } } } + + if servedCount > 0 { + SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))…", category: .sync) + } } // Build REQUEST_SYNC payload using current candidates and GCS params @@ -664,6 +677,7 @@ final class GossipSyncManager { // One request per due schedule rather than a union filter: each type // group gets the full GCS capacity and its own since-cursor, so heavy // fragment traffic can't crowd messages out of the filter. + var dueLabels: [String] = [] for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } // No board source wired up means nothing to offer or store; @@ -672,8 +686,14 @@ final class GossipSyncManager { if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now sendPeriodicSync(for: syncSchedules[index].types) + dueLabels.append(syncSchedules[index].types.debugShortLabel) } } + // One concise summary per maintenance cycle, not per packet. + if !dueLabels.isEmpty { + let peerCount = delegate?.getConnectedPeers().count ?? 0 + SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync) + } } private func cleanupStaleAnnouncementsIfNeeded(now: Date) { diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 2eea6efc..356c31c7 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -150,4 +150,19 @@ struct SyncTypeFlags: OptionSet { } return SyncTypeFlags(rawValue: raw) } + + /// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre". + /// (Referenced only from DEBUG log lines, but kept unconditional so the + /// log-call arguments compile in release too, where the log itself no-ops.) + var debugShortLabel: String { + var parts: [String] = [] + if contains(.announce) { parts.append("ann") } + if contains(.message) { parts.append("msg") } + if contains(.fragment) { parts.append("frag") } + if contains(.fileTransfer) { parts.append("file") } + if contains(.prekeyBundle) { parts.append("pre") } + if contains(.groupMessage) { parts.append("grp") } + if contains(.boardPost) { parts.append("board") } + return parts.isEmpty ? "none" : parts.joined(separator: ",") + } } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 008e8e4f..7adc3905 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -1,4 +1,12 @@ import SwiftUI +#if DEBUG +import BitLogger +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif +#endif struct AppInfoView: View { @Environment(\.dismiss) var dismiss @@ -10,6 +18,14 @@ struct AppInfoView: View { var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? @State private var showTopology = false + #if DEBUG + // Opt-in LAN log streaming + in-app export. DEBUG-only; empty host = off. + @AppStorage(LogNetworkSink.hostDefaultsKey) private var logSinkHost = "" + @AppStorage(LogNetworkSink.portDefaultsKey) private var logSinkPort = LogNetworkSink.defaultPort + @State private var exportedLogURL: URL? + @State private var isPresentingLogShare = false + #endif + private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } @@ -293,11 +309,122 @@ struct AppInfoView: View { FeatureRow(info: Strings.Privacy.panic) } + + #if DEBUG + debugSection + #endif } .padding() } + + #if DEBUG + // Tester-only observability panel: live LAN log streaming + a logs export. + // Never compiled into release (privacy-first: release ships no such UI). + @ViewBuilder + private var debugSection: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader("debug & logs") + + Text("Stream sanitized logs over the LAN to a collector (nc -lu \(logSinkPort)). Empty host = off.") + .bitchatFont(size: 12) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + TextField("collector host (e.g. 192.168.1.5)", text: $logSinkHost) + .textFieldStyle(.roundedBorder) + .bitchatFont(size: 13) + #if os(iOS) + .keyboardType(.numbersAndPunctuation) + .autocapitalization(.none) + .disableAutocorrection(true) + #endif + .accessibilityLabel(Text("log collector host")) + + TextField("port", value: $logSinkPort, format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .bitchatFont(size: 13) + .frame(width: 70) + #if os(iOS) + .keyboardType(.numberPad) + #endif + .accessibilityLabel(Text("log collector port")) + } + .onChange(of: logSinkHost) { _ in LogNetworkSink.shared.reloadConfiguration() } + .onChange(of: logSinkPort) { _ in LogNetworkSink.shared.reloadConfiguration() } + + Button(action: exportLogs) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "square.and.arrow.up") + .font(.bitchatSystem(size: 20)) + .foregroundColor(textColor) + .frame(width: 30) + VStack(alignment: .leading, spacing: 4) { + Text("Export Logs") + .bitchatFont(size: 14, weight: .semibold) + .foregroundColor(textColor) + Text("Share the recent in-memory log buffer as a .txt file.") + .bitchatFont(size: 12) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Export logs")) + .accessibilityHint(Text("Shares the recent log buffer as a text file")) + } + #if os(iOS) + .sheet(isPresented: $isPresentingLogShare) { + if let exportedLogURL { + LogShareSheet(activityItems: [exportedLogURL]) + } + } + #endif + } + + /// Write the buffered log to a temp `.txt` and present the platform share. + private func exportLogs() { + let text = LogExportBuffer.shared.snapshot() + let stamp = ISO8601DateFormatter().string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("bitchat-logs-\(stamp).txt") + do { + try text.data(using: .utf8)?.write(to: url) + } catch { + return + } + #if os(iOS) + exportedLogURL = url + isPresentingLogShare = true + #elseif os(macOS) + let panel = NSSavePanel() + panel.nameFieldStringValue = url.lastPathComponent + panel.allowedContentTypes = [.plainText] + if panel.runModal() == .OK, let dest = panel.url { + try? text.data(using: .utf8)?.write(to: dest) + } + #endif + } + #endif } +#if DEBUG && os(iOS) +/// Minimal UIActivityViewController bridge for the Export Logs share sheet. +private struct LogShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} +#endif + struct AppInfoFeatureInfo { let icon: String let title: LocalizedStringKey diff --git a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift index c762a0c7..96b8d434 100644 --- a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift +++ b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift @@ -32,13 +32,13 @@ struct BLESourceRouteOriginationPolicyTests { ) } - private func route( + private func decide( packet: BitchatPacket, isRecipientConnected: Bool = false, shouldAttemptRoute: Bool = true, computedRoute: [Data]? = nil - ) -> [Data]? { - BLESourceRouteOriginationPolicy.route( + ) -> BLESourceRouteOriginationPolicy.Decision { + BLESourceRouteOriginationPolicy.decide( for: packet, to: recipient, localPeerIDData: localPeerIDData, @@ -49,38 +49,38 @@ struct BLESourceRouteOriginationPolicyTests { } @Test func routesWhenAllGatesPass() { - #expect(route(packet: makePacket()) == [hop]) + #expect(decide(packet: makePacket()) == .route([hop])) } @Test func relayedPacketNeverGetsRoute() { let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) - #expect(route(packet: relayed) == nil) + #expect(decide(packet: relayed) == .flood(.relayedNotOriginator)) } @Test func broadcastRecipientNeverGetsRoute() { let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) - #expect(route(packet: broadcast) == nil) + #expect(decide(packet: broadcast) == .flood(.broadcast)) let noRecipient = makePacket(recipientID: nil) - #expect(route(packet: noRecipient) == nil) + #expect(decide(packet: noRecipient) == .flood(.broadcast)) } @Test func linkLocalTTLNeverGetsRoute() { // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. - #expect(route(packet: makePacket(ttl: 0)) == nil) - #expect(route(packet: makePacket(ttl: 1)) == nil) + #expect(decide(packet: makePacket(ttl: 0)) == .flood(.noTTLHeadroom)) + #expect(decide(packet: makePacket(ttl: 1)) == .flood(.noTTLHeadroom)) } @Test func directlyConnectedRecipientNeverGetsRoute() { - #expect(route(packet: makePacket(), isRecipientConnected: true) == nil) + #expect(decide(packet: makePacket(), isRecipientConnected: true) == .flood(.recipientDirect)) } @Test func suppressedRecipientFallsBackToFlood() { - #expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil) + #expect(decide(packet: makePacket(), shouldAttemptRoute: false) == .flood(.routeSuppressed)) } @Test func missingOrEmptyRouteFallsBackToFlood() { var sawComputeRoute = false - let result = BLESourceRouteOriginationPolicy.route( + let result = BLESourceRouteOriginationPolicy.decide( for: makePacket(), to: recipient, localPeerIDData: localPeerIDData, @@ -91,8 +91,8 @@ struct BLESourceRouteOriginationPolicyTests { return nil } ) - #expect(result == nil) + #expect(result == .flood(.noPath)) #expect(sawComputeRoute) - #expect(route(packet: makePacket(), computedRoute: []) == nil) + #expect(decide(packet: makePacket(), computedRoute: []) == .flood(.noPath)) } } diff --git a/localPackages/BitLogger/Sources/LogExportBuffer.swift b/localPackages/BitLogger/Sources/LogExportBuffer.swift new file mode 100644 index 00000000..0f740256 --- /dev/null +++ b/localPackages/BitLogger/Sources/LogExportBuffer.swift @@ -0,0 +1,64 @@ +// +// LogExportBuffer.swift +// BitLogger +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if DEBUG +import Foundation + +/// In-memory ring buffer of the most recent sanitized log lines, so a tester +/// can export logs untethered when live streaming isn't available. +/// +/// DEBUG-only: never compiled into release builds, matching the rest of the +/// logging stack. Bounded by both a line count and a byte budget (oldest +/// evicted first). Appends run on a private serial queue so a hot logging path +/// never blocks on buffer maintenance and the main thread is never touched. +/// +/// It stores exactly the SecureLogger-sanitized text (fingerprints truncated, +/// base64 redacted, peer IDs shortened), so the export carries no secrets. +public final class LogExportBuffer { + public static let shared = LogExportBuffer() + + private let queue = DispatchQueue(label: "chat.bitchat.securelogger.export", qos: .utility) + private var lines: [String] = [] + private var byteCount = 0 + + private let maxLines: Int + private let maxBytes: Int + + init(maxLines: Int = 2000, maxBytes: Int = 512 * 1024) { + self.maxLines = maxLines + self.maxBytes = maxBytes + } + + /// Append one already-formatted, already-sanitized log line. Non-blocking + /// (async on the private queue). + func append(_ line: String) { + queue.async { + self.lines.append(line) + self.byteCount += line.utf8.count + 1 // + newline + while self.lines.count > self.maxLines || self.byteCount > self.maxBytes { + guard !self.lines.isEmpty else { break } + let removed = self.lines.removeFirst() + self.byteCount -= (removed.utf8.count + 1) + } + } + } + + /// A newline-joined snapshot of the buffered lines, oldest first. Safe to + /// call from the main thread (brief synchronous read). + public func snapshot() -> String { + queue.sync { lines.joined(separator: "\n") } + } + + public func clear() { + queue.async { + self.lines.removeAll(keepingCapacity: true) + self.byteCount = 0 + } + } +} +#endif diff --git a/localPackages/BitLogger/Sources/LogNetworkSink.swift b/localPackages/BitLogger/Sources/LogNetworkSink.swift new file mode 100644 index 00000000..84ea6e87 --- /dev/null +++ b/localPackages/BitLogger/Sources/LogNetworkSink.swift @@ -0,0 +1,104 @@ +// +// LogNetworkSink.swift +// BitLogger +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if DEBUG +import Foundation +#if canImport(Network) +import Network +#endif + +/// Best-effort, fire-and-forget UDP forwarder for sanitized log lines, so a +/// sideloaded device on iOS 17+ (where `idevicesyslog` Wi-Fi streaming no +/// longer works) can stream its logs to a LAN collector with no cable. +/// +/// Design constraints: +/// - DEBUG-only: never compiled into release. A privacy-first release build +/// carries zero network-log code. +/// - Opt-in and off by default: nothing is ever sent until a collector host +/// is configured. An empty host means no egress. +/// - Never blocks the caller and never throws into the app: every send runs +/// on a private queue and failures are silently dropped. A dead or absent +/// collector cannot affect app behavior (UDP is connectionless — datagrams +/// to nowhere are simply lost). +/// - Forwards the exact same sanitized text the ring buffer captures, so no +/// secrets leave the device. Each line is prefixed with the device's label +/// (`[nickname] …`) so one Mac-side `nc -lu ` can demux all devices. +public final class LogNetworkSink { + public static let shared = LogNetworkSink() + + /// UserDefaults keys shared with the in-app config UI (App Info sheet). + public static let hostDefaultsKey = "debug.logSink.host" + public static let portDefaultsKey = "debug.logSink.port" + public static let defaultPort = 9999 + /// Where the device label is read from (the app's chosen nickname). + public static let nicknameDefaultsKey = "bitchat.nickname" + + private let queue = DispatchQueue(label: "chat.bitchat.securelogger.netsink", qos: .utility) + private let defaults: UserDefaults + private var label = "" + private var enabled = false + #if canImport(Network) + private var connection: NWConnection? + #endif + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Re-read collector host/port and the device label from UserDefaults and + /// (re)build the UDP connection. Call on launch and whenever the config UI + /// changes. An empty host tears the sink down (no egress). + public func reloadConfiguration() { + let host = (defaults.string(forKey: Self.hostDefaultsKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let storedPort = defaults.integer(forKey: Self.portDefaultsKey) + let port = storedPort > 0 ? storedPort : Self.defaultPort + let label = (defaults.string(forKey: Self.nicknameDefaultsKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + configure(host: host, port: port, label: label) + } + + /// Point the sink at `host:port` with a device `label`. Empty host or an + /// invalid port disables egress. + public func configure(host: String, port: Int, label: String) { + queue.async { + self.label = label + #if canImport(Network) + self.connection?.cancel() + self.connection = nil + guard !host.isEmpty, + (1...65535).contains(port), + let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + self.enabled = false + return + } + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .udp) + connection.start(queue: self.queue) + self.connection = connection + self.enabled = true + #else + self.enabled = false + #endif + } + } + + /// Forward one already-formatted, already-sanitized line. Non-blocking; + /// drops silently when disabled or on any send failure. + func send(_ line: String) { + queue.async { + guard self.enabled else { return } + #if canImport(Network) + guard let connection = self.connection else { return } + let prefixed = self.label.isEmpty ? line : "[\(self.label)] \(line)" + guard let data = (prefixed + "\n").data(using: .utf8) else { return } + connection.send(content: data, completion: .idempotent) + #endif + } + } +} +#endif diff --git a/localPackages/BitLogger/Sources/OSLog+Categories.swift b/localPackages/BitLogger/Sources/OSLog+Categories.swift index 1d74e27e..7fe19ed7 100644 --- a/localPackages/BitLogger/Sources/OSLog+Categories.swift +++ b/localPackages/BitLogger/Sources/OSLog+Categories.swift @@ -20,4 +20,11 @@ public extension OSLog { static let security = OSLog(subsystem: subsystem, category: "security") static let handshake = OSLog(subsystem: subsystem, category: "handshake") static let sync = OSLog(subsystem: subsystem, category: "sync") + // Added for the observability pass: coarse filtering buckets for the mesh + // routing layer, the internet gateway bridge, and the Wi-Fi bulk transport. + // The bracket tags in the messages ([ROUTE]/[GW]/[WIFI]/…) remain the + // primary filter; these just let `log stream --category` narrow further. + static let mesh = OSLog(subsystem: subsystem, category: "mesh") + static let gateway = OSLog(subsystem: subsystem, category: "gateway") + static let transport = OSLog(subsystem: subsystem, category: "transport") } diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index 5aaffd53..4377eb4a 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -86,6 +86,17 @@ public final class SecureLogger { case .fault: return 4 } } + + /// Short uppercase label for the in-memory export buffer line prefix. + var label: String { + switch self { + case .debug: return "DEBUG" + case .info: return "INFO" + case .warning: return "WARN" + case .error: return "ERROR" + case .fault: return "FAULT" + } + } var osLogType: OSLogType { switch self { @@ -100,18 +111,34 @@ public final class SecureLogger { // MARK: - Global Threshold - /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. + /// Minimum level that will be logged. Override via env BITCHAT_LOG_LEVEL + /// (env always wins). The default is `.debug` in DEBUG builds and `.info` + /// otherwise: a sideloaded, tap-launched build can't receive the env var, + /// so DEBUG must surface the `.debug` decision logs out of the box. Release + /// emits nothing regardless (all log paths are `#if DEBUG`). /// Internal-settable so tests can verify level filtering; app code should not mutate it. - internal static var minimumLevel: LogLevel = { + internal static var minimumLevel: LogLevel = defaultMinimumLevel + + /// The level used when nothing mutates `minimumLevel`. Honors the + /// BITCHAT_LOG_LEVEL env override; otherwise `.debug` in DEBUG builds (so a + /// tap-launched sideload surfaces decision logs) and `.info` elsewhere. + /// Exposed for tests. + internal static var defaultMinimumLevel: LogLevel { let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() switch env { case "debug": return .debug + case "info": return .info case "warning": return .warning case "error": return .error case "fault": return .fault - default: return .info + default: + #if DEBUG + return .debug + #else + return .info + #endif } - }() + } private static func shouldLog(_ level: LogLevel) -> Bool { return level.order >= minimumLevel.order @@ -168,6 +195,7 @@ public extension SecureLogger { #if DEBUG os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) + record(level: .error, message: "\(location) Error in \(sanitized): \(errorDesc)") #else os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) #endif @@ -258,9 +286,10 @@ private extension SecureLogger { let location = formatLocation(file: file, line: line, function: function) let sanitized = "\(location) \(message())".sanitized() os_log("%{public}@", log: category, type: level.osLogType, sanitized) + record(level: level, message: sanitized) #endif } - + /// Log a security event static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String, line: Int, function: String) { @@ -269,6 +298,18 @@ private extension SecureLogger { let location = formatLocation(file: file, line: line, function: function) let message = "\(location) \(event.message)" os_log("%{public}@", log: .security, type: level.osLogType, message) + record(level: level, message: message) + #endif + } + + /// Fan a sanitized line out to the in-memory export buffer and the + /// (opt-in, off by default) UDP network sink. DEBUG-only; both consumers + /// are non-blocking. Same formatted text everywhere. + static func record(level: LogLevel, message: String) { + #if DEBUG + let line = "[\(level.label)] \(message)" + LogExportBuffer.shared.append(line) + LogNetworkSink.shared.send(line) #endif } diff --git a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift index db04759f..47b28dea 100644 --- a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift +++ b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift @@ -51,4 +51,25 @@ final class LogLevelFilteringTests: XCTestCase { XCTAssertEqual(counter.count, 1, "Enabled levels must still log") } + + /// A sideloaded, tap-launched build can't receive BITCHAT_LOG_LEVEL, so the + /// DEBUG default must be `.debug` (not `.info`) or the `.debug` decision + /// logs stay invisible on device. Release still defaults to `.info` and + /// emits nothing regardless (all log paths are `#if DEBUG`). + func testDebugBuildDefaultsToDebugLevelUnlessOverridden() throws { + // Only meaningful when the env override isn't set (CI sometimes sets it). + try XCTSkipUnless( + ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"] == nil, + "BITCHAT_LOG_LEVEL is set; default-level assertion doesn't apply" + ) + #if DEBUG + XCTAssertEqual( + SecureLogger.defaultMinimumLevel, + .debug, + "DEBUG builds must default to .debug so tap-launched sideloads surface decision logs" + ) + #else + XCTAssertEqual(SecureLogger.defaultMinimumLevel, .info) + #endif + } }