mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 16:05:18 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
d4f0c49787
commit
f9032cf2b9
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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")]
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user