Add mesh diagnostics: /ping, /trace, and topology map (#1377)

* Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing

Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Conform DiagnosticsMockContext to sendPublicMessage

CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after
this branch forked, so the diagnostics test mock no longer conformed once
main was merged in. Add the no-op stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 15:08:22 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent d4f0c49787
commit f9032cf2b9
22 changed files with 1320 additions and 7 deletions
+22
View File
@@ -62,6 +62,28 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
let isSelf = peerID == snapshot.localPeerID
let label: String
if isSelf {
label = chatViewModel.nickname
} else {
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))"
}
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
}
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
+133 -1
View File
@@ -5142,7 +5142,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "symbols"
"value" : "SYMBOLS"
}
}
}
@@ -5171,6 +5171,54 @@
}
}
},
"app_info.network.title" : {
"comment" : "Section header for network diagnostics in the app info sheet",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "NETWORK"
}
}
}
},
"app_info.network.topology.description" : {
"comment" : "Row description for the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "map of peers and links learned from mesh announces"
}
}
}
},
"app_info.network.topology.hint" : {
"comment" : "Accessibility hint for the mesh topology row",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "opens the mesh topology map"
}
}
}
},
"app_info.network.topology.title" : {
"comment" : "Row title opening the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mesh topology"
}
}
}
},
"app_info.privacy.ephemeral.description" : {
"extractionState" : "manual",
"localizations" : {
@@ -15449,6 +15497,18 @@
}
}
},
"content.commands.ping" : {
"comment" : "Description of the /ping command in the suggestions panel",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "measure round-trip time to a mesh peer"
}
}
}
},
"content.commands.slap" : {
"extractionState" : "manual",
"localizations" : {
@@ -15628,6 +15688,18 @@
}
}
},
"content.commands.trace" : {
"comment" : "Description of the /trace command in the suggestions panel",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "estimate the mesh path to a peer"
}
}
}
},
"content.commands.unblock" : {
"extractionState" : "manual",
"localizations" : {
@@ -35384,6 +35456,66 @@
}
}
},
"topology.caption" : {
"comment" : "Caption under the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"
}
}
}
},
"topology.empty" : {
"comment" : "Empty state of the mesh topology map",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "no mesh links yet — the map fills in as peer announces arrive"
}
}
}
},
"topology.refresh" : {
"comment" : "Accessibility label of the topology refresh button",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "refresh topology"
}
}
}
},
"topology.summary" : {
"comment" : "Topology map summary: number of peers and links",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$ld peers · %2$ld links"
}
}
}
},
"topology.title" : {
"comment" : "Title of the mesh topology map sheet",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mesh topology"
}
}
}
},
"verification.my_qr.accessibility_label" : {
"extractionState" : "manual",
"localizations" : {
+8 -4
View File
@@ -25,6 +25,8 @@ enum CommandInfo: String, Identifiable {
case who
case favorite = "fav"
case unfavorite = "unfav"
case ping
case trace
var id: String { rawValue }
@@ -32,7 +34,7 @@ enum CommandInfo: String, Identifiable {
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
@@ -54,6 +56,8 @@ enum CommandInfo: String, Identifiable {
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
}
}
@@ -66,11 +70,11 @@ enum CommandInfo: String, Identifiable {
if !isGeoPublic {
commands.append(.pay)
}
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
// The processor rejects favorites and mesh diagnostics in geohash
// contexts, so only suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return commands
}
return commands + [.favorite, .unfavorite]
return commands + [.favorite, .unfavorite, .ping, .trace]
}
}
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier:
return false
}
}
@@ -51,10 +51,15 @@ struct BLEReceivePipeline {
// Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp.
// Ping/pong diagnostics ride it too: probes need the same
// deterministic multi-hop relay as DMs (always relay, jitter,
// no TTL cap) so RTT and hop counts reflect the real path.
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
// the same multi-hop treatment to reach a non-adjacent gateway.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue
|| packet.type == MessageType.ping.rawValue
|| packet.type == MessageType.pong.rawValue
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
+171
View File
@@ -80,6 +80,24 @@ final class BLEService: NSObject {
// Route health for originated source routes; guarded by collectionsQueue.
private var sourceRouteFailures = BLESourceRouteFailureCache()
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
// inbound ping budget keyed by the ingress link (the directly connected
// peer that delivered the packet), since the unsigned claimed sender is
// spoofable so a directed unencrypted probe cannot be turned into an
// amplification primitive. Both are owned by collectionsQueue barriers
// like the other mutable collections.
private struct PendingMeshPing {
let peerID: PeerID
let sentAt: Date
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
private var meshPingResponseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
// 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
@@ -2493,6 +2511,150 @@ extension BLEService {
PeerID(routingData: data)
}
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
/// The completion fires exactly once on the main actor: with RTT/hops
/// when the matching pong returns, or nil after the timeout window.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
messageQueue.async { [weak self] in
guard let self,
let recipientData = peerID.toShort().routingData,
let payload = MeshPingPayload(
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
originTTL: self.messageTTL
) else {
Task { @MainActor in completion(nil) }
return
}
let nonce = payload.nonce
let packet = BitchatPacket(
type: MessageType.ping.rawValue,
senderID: self.myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload.encode(),
signature: nil,
ttl: self.messageTTL
)
let timeout = DispatchWorkItem { [weak self] in
guard let self else { return }
let expired = self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings.removeValue(forKey: nonce)
}
guard let expired else { return }
Task { @MainActor in expired.completion(nil) }
}
self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings[nonce] = PendingMeshPing(
peerID: PeerID(hexData: recipientData),
sentAt: Date(),
completion: completion,
timeout: timeout
)
}
self.messageQueue.asyncAfter(
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
execute: timeout
)
self.broadcastPacket(packet)
}
}
/// Answers a ping addressed to us with a pong echoing its nonce; pings
/// addressed elsewhere are left to the generic directed-relay path.
///
/// `linkPeerID` is the directly connected peer that delivered the packet
/// (the ingress link), NOT the packet's claimed sender: pings are
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
/// response budget on it would let one connected peer rotate forged
/// sender IDs to emit unbounded pongs. The budget is per physical link;
/// the pong still goes to the claimed sender (that's the protocol).
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)
return
}
let allowed = collectionsQueue.sync(flags: .barrier) {
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
}
guard allowed else {
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))", category: .security)
}
return
}
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
let reply = BitchatPacket(
type: MessageType.pong.rawValue,
senderID: myPeerIDData,
recipientID: packet.senderID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: pong.encode(),
signature: nil,
ttl: messageTTL
)
broadcastPacket(reply)
}
/// Resolves a pong against its outstanding probe. The unguessable echoed
/// nonce plus the sender check bind the reply to the probed peer; hops
/// come from the pong's TTL decrements on the return path.
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
return pendingMeshPings.removeValue(forKey: pong.nonce)
}
guard let pending else { return }
pending.timeout.cancel()
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
let result = MeshPingResult(
rttMs: max(0, rttMs),
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
)
Task { @MainActor in pending.completion(result) }
}
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
refreshLocalTopology()
if let route = computeRoute(to: peerID) {
return route.compactMap { PeerID(routingData: $0) }
}
// 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
}
/// Mesh graph for the topology map. Edges are advisory: announces cap
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
func currentMeshTopology() -> MeshTopologySnapshot? {
refreshLocalTopology()
let claims = meshTopology.adjacencySnapshot()
var nodes = Set<PeerID>()
var edges = Set<MeshTopologyEdge>()
for (source, neighbors) in claims {
guard let sourcePeer = PeerID(routingData: source) else { continue }
nodes.insert(sourcePeer)
for neighborData in neighbors {
guard let neighborPeer = PeerID(routingData: neighborData),
neighborPeer != sourcePeer else { continue }
nodes.insert(neighborPeer)
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
}
}
nodes.insert(myPeerID)
return MeshTopologySnapshot(
localPeerID: myPeerID,
nodes: nodes.sorted(),
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
)
}
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
let plan = BLERouteForwardingPolicy.plan(
@@ -3381,6 +3543,15 @@ extension BLEService {
case .nostrCarrier:
handleNostrCarrier(packet, from: peerID)
case .ping:
// Rate limiting must key on the ingress link (`peerID`), not the
// packet-claimed sender: pings are unsigned, so `senderID` is
// attacker-controlled and rotating it would reset the budget.
handleMeshPing(packet, fromLink: peerID)
case .pong:
handleMeshPong(packet, from: senderID)
case .leave:
handleLeave(packet, from: senderID)
+96
View File
@@ -22,6 +22,17 @@ struct CommandGeoParticipant {
let displayName: String
}
/// The conversation a command was typed into, captured when the command is
/// issued so deferred output (e.g. an async /ping result, which can arrive
/// many seconds later) lands there even if the user switches chats first.
enum CommandOutputDestination: Equatable {
/// The #mesh public timeline. Commands that defer output (/ping) are
/// mesh-only, so a non-DM origin is always the mesh timeline.
case meshTimeline
/// The private chat that was open when the command was typed.
case privateChat(PeerID)
}
/// Protocol defining what CommandProcessor needs from its context.
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
@MainActor
@@ -51,6 +62,13 @@ protocol CommandContextProvider: AnyObject {
// MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func addPublicSystemMessage(_ content: String)
/// The conversation the user is typing into right now. Commands that
/// finish asynchronously capture this BEFORE starting async work, so a
/// chat switch cannot misroute their deferred output.
func currentCommandDestination() -> CommandOutputDestination
/// Routes deferred command output (e.g. an async /ping result) into the
/// conversation captured when the command was issued.
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
// MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
@@ -108,6 +126,12 @@ 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 "/ping":
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
return handlePing(args)
case "/trace":
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
return handleTrace(args)
case "/pay":
return handlePay(args)
case "/help":
@@ -129,6 +153,8 @@ final class CommandProcessor {
/slap @name — slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name — favorites (mesh only)
/ping @name — measure round-trip time (mesh only)
/trace @name — estimated mesh path (mesh only)
/pay <token> — send a cashu ecash token in this chat
/help — this list
"""
@@ -336,6 +362,76 @@ final class CommandProcessor {
return .error(message: "cannot unblock \(nickname): not found")
}
// MARK: - Mesh Diagnostics
private enum MeshPeerResolution {
case resolved(peerID: PeerID, nickname: String)
case failed(CommandResult)
}
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
/// rejected diagnostics measure the BLE mesh, not Nostr.
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .failed(.error(message: "usage: /\(command) <nickname>"))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
!peerID.isGeoDM, !peerID.isGeoChat else {
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
}
return .resolved(peerID: peerID, nickname: nickname)
}
private func handlePing(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "ping") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
let nickname = target.nickname
let currentProvider = contextProvider
// Capture the origin conversation now: the pong can arrive up to
// meshPingTimeoutSeconds later, and reading the selected chat at
// callback time would misroute the result after a chat switch.
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
return
}
let hopText: String = result.hops.map { hops in
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
} ?? ""
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
}
return .success(message: "pinging \(nickname)")
}
private func handleTrace(_ args: String) -> CommandResult {
let target: (peerID: PeerID, nickname: String)
switch resolveMeshPeer(args, command: "trace") {
case .resolved(let peerID, let nickname): target = (peerID, nickname)
case .failed(let result): return result
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded
// present it as an estimate.
let hopNames = intermediates.map { hop in
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))"
}
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: "")
let hops = intermediates.count + 1
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
}
/// `/pay <cashu-token>` validates the token decodes, then sends it as
/// the message body in the current chat. Cashu tokens are bearer
/// instruments (whoever redeems first gets the funds), so posting one to
@@ -49,6 +49,13 @@ final class MeshTopologyTracker {
}
}
/// Raw directed neighbor claims, for diagnostics (topology map, /trace).
/// Callers treat the claims as advisory: announces cap `directNeighbors`
/// at 10, so an edge may be claimed by only one of its endpoints.
func adjacencySnapshot() -> [Data: Set<Data>] {
queue.sync { claims }
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
+53
View File
@@ -31,6 +31,40 @@ struct TransportPeerSnapshot: Equatable, Hashable {
}
}
/// Outcome of a `/ping` probe over the mesh.
struct MeshPingResult: Equatable {
/// Round-trip time in milliseconds.
let rttMs: Int
/// Total hops to the peer (1 = directly connected), derived from the
/// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
let hops: Int?
}
/// Undirected mesh link between two peers, normalized so `(a, b)` and
/// `(b, a)` collapse to one edge.
struct MeshTopologyEdge: Hashable {
let a: PeerID
let b: PeerID
init(_ first: PeerID, _ second: PeerID) {
if first < second {
a = first
b = second
} else {
a = second
b = first
}
}
}
/// Point-in-time view of the mesh graph learned from gossiped announces
/// (each announce carries up to 10 `directNeighbors`).
struct MeshTopologySnapshot: Equatable {
let localPeerID: PeerID
let nodes: [PeerID]
let edges: [MeshTopologyEdge]
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -128,6 +162,17 @@ protocol Transport: AnyObject {
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -174,6 +219,14 @@ extension Transport {
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
+5
View File
@@ -16,6 +16,11 @@ enum TransportConfig {
static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
// Mesh diagnostics (/ping)
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)...
static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
+3
View File
@@ -40,6 +40,9 @@ struct SyncTypeFlags: OptionSet {
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
// Ping/pong are ephemeral directed probes; replaying them via gossip
// sync would only produce stale, unanswerable echoes.
case .ping, .pong: return nil
// Gateway carriers are ephemeral live traffic (uplinks are directed,
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
// would waste airtime and extend their lifetime.
+27
View File
@@ -1547,6 +1547,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
}
/// Origin conversation for deferred command output, captured when the
/// command is issued (before any async work starts).
@MainActor
func currentCommandDestination() -> CommandOutputDestination {
if let peerID = selectedPrivateChatPeer {
return .privateChat(peerID)
}
// Deferring commands (/ping) are rejected in geohash channels, so a
// non-DM origin is always the #mesh timeline.
return .meshTimeline
}
/// Routes deferred command output (async /ping results) into the
/// conversation captured at issue time, immune to chat switches in the
/// meantime. A DM result lands in the origin chat's history even if that
/// chat is no longer selected (or was cleared it then reappears as the
/// first message when the chat is reopened).
@MainActor
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
switch destination {
case .privateChat(let peerID):
addLocalPrivateSystemMessage(content, to: peerID)
case .meshTimeline:
publicConversationCoordinator.addMeshOnlySystemMessage(content)
}
}
// MARK: - Message Reception
@MainActor
+45
View File
@@ -5,6 +5,11 @@ struct AppInfoView: View {
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
/// Supplies the mesh topology map data. Nil (previews, missing wiring)
/// hides the topology row entirely.
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
@State private var showTopology = false
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
}
@@ -75,6 +80,15 @@ struct AppInfoView: View {
]
}
enum Network {
static let title: LocalizedStringKey = "app_info.network.title"
static let topology = AppInfoFeatureInfo(
icon: "point.3.connected.trianglepath.dotted",
title: "app_info.network.topology.title",
description: "app_info.network.topology.description"
)
}
enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -129,6 +143,11 @@ struct AppInfoView: View {
.themedSheetBackground()
}
.frame(width: 600, height: 700)
.sheet(isPresented: $showTopology) {
if let topologyProvider {
MeshTopologyView(provider: topologyProvider)
}
}
#else
NavigationView {
ScrollView {
@@ -143,6 +162,11 @@ struct AppInfoView: View {
}
}
}
.sheet(isPresented: $showTopology) {
if let topologyProvider {
MeshTopologyView(provider: topologyProvider)
}
}
#endif
}
@@ -199,6 +223,27 @@ struct AppInfoView: View {
.foregroundColor(textColor)
}
// Network diagnostics
if topologyProvider != nil {
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Network.title)
Button {
showTopology = true
} label: {
HStack(spacing: 0) {
FeatureRow(info: Strings.Network.topology)
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 12))
.foregroundColor(secondaryTextColor)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text("app_info.network.topology.hint"))
}
}
// Features
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title)
+1 -1
View File
@@ -149,7 +149,7 @@ struct ContentView: View {
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
AppInfoView()
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
+217
View File
@@ -0,0 +1,217 @@
//
// MeshTopologyView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// Display model for the mesh topology map: nodes are known mesh peers,
/// edges are gossiped `directNeighbors` claims. Built on the main actor from
/// a `MeshTopologySnapshot` plus the current nickname table.
struct MeshTopologyDisplayModel {
struct Node: Identifiable, Equatable {
let id: String
let label: String
let isSelf: Bool
}
let nodes: [Node]
/// Pairs of `Node.id`; every id is present in `nodes`.
let edges: [(String, String)]
static let empty = MeshTopologyDisplayModel(nodes: [], edges: [])
}
/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in
/// the center), drawn with Canvas so it stays cheap at any peer count.
struct MeshTopologyView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appTheme) private var appTheme
@ThemedPalette private var palette
/// Fetches a fresh model; called on appear and on manual refresh.
let provider: @MainActor () -> MeshTopologyDisplayModel
@State private var model: MeshTopologyDisplayModel = .empty
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
HStack {
Text("topology.title")
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(palette.primary)
Spacer()
refreshButton
Button("app_info.done") {
dismiss()
}
.buttonStyle(.plain)
.foregroundColor(palette.primary)
}
.padding()
.themedSurface(opacity: 0.95)
content
}
.frame(width: 500, height: 520)
.themedSheetBackground()
#else
NavigationView {
content
.themedSheetBackground()
.navigationTitle(Text("topology.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
refreshButton
}
ToolbarItem(placement: .navigationBarTrailing) {
SheetCloseButton { dismiss() }
.foregroundColor(palette.primary)
}
}
}
#endif
}
private var refreshButton: some View {
Button {
model = provider()
} label: {
Image(systemName: "arrow.clockwise")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("topology.refresh"))
}
@ViewBuilder
private var content: some View {
VStack(spacing: 12) {
if model.nodes.count <= 1 {
Spacer()
Text("topology.empty")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Spacer()
} else {
graphCanvas
.padding(.horizontal, 8)
}
VStack(spacing: 4) {
Text(summaryText)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
Text("topology.caption")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
.padding(.bottom, 16)
}
.onAppear { model = provider() }
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(summaryText))
}
private var summaryText: String {
String(
format: String(
localized: "topology.summary",
comment: "Topology map summary: number of peers and links"
),
locale: .current,
model.nodes.count,
model.edges.count
)
}
private var graphCanvas: some View {
Canvas { context, size in
let positions = Self.layout(nodes: model.nodes, in: size)
let fontDesign = appTheme.bodyFontDesign
// Edges first so nodes draw on top.
for (fromID, toID) in model.edges {
guard let from = positions[fromID], let to = positions[toID] else { continue }
var path = Path()
path.move(to: from)
path.addLine(to: to)
context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1)
}
for node in model.nodes {
guard let center = positions[node.id] else { continue }
let radius: CGFloat = node.isSelf ? 7 : 5
let dot = Path(ellipseIn: CGRect(
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2
))
context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary))
if node.isSelf {
let ring = Path(ellipseIn: CGRect(
x: center.x - radius - 3,
y: center.y - radius - 3,
width: (radius + 3) * 2,
height: (radius + 3) * 2
))
context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1)
}
context.draw(
Text(node.label)
.font(.system(size: 10, design: fontDesign))
.foregroundColor(node.isSelf ? palette.accent : palette.secondary),
at: CGPoint(x: center.x, y: center.y + radius + 4),
anchor: .top
)
}
}
.accessibilityHidden(true) // The combined summary label narrates the graph.
}
/// Circular layout: self in the center, everyone else evenly spaced on a
/// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle.
static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] {
let center = CGPoint(x: size.width / 2, y: size.height / 2)
// Leave room for the label row under each ring node.
let radius = max(20, min(size.width, size.height) / 2 - 36)
var positions: [String: CGPoint] = [:]
let ringNodes = nodes.filter { !$0.isSelf }
for node in nodes where node.isSelf {
positions[node.id] = center
}
for (index, node) in ringNodes.enumerated() {
let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2
positions[node.id] = CGPoint(
x: center.x + radius * cos(angle),
y: center.y + radius * sin(angle)
)
}
return positions
}
}
#Preview("Topology") {
MeshTopologyView(provider: {
MeshTopologyDisplayModel(
nodes: [
.init(id: "self", label: "me", isSelf: true),
.init(id: "a", label: "alice", isSelf: false),
.init(id: "b", label: "bob", isSelf: false),
.init(id: "c", label: "carol", isSelf: false)
],
edges: [("self", "a"), ("a", "b"), ("self", "c")]
)
})
}