From 9e80764c2b3987c430cdfef7efaa18a6b63198f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 04:34:00 +0000 Subject: [PATCH] feat(ios): add discovery debug logs --- .../Bridge/BridgeConnectionController.swift | 10 +++ .../Bridge/BridgeDiscoveryDebugLogView.swift | 69 +++++++++++++++++++ .../Sources/Bridge/BridgeDiscoveryModel.swift | 51 +++++++++++++- apps/ios/Sources/Settings/SettingsTab.swift | 10 +++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index b3a053aa6..48013f20c 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -8,6 +8,7 @@ import SwiftUI final class BridgeConnectionController: ObservableObject { @Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = [] @Published private(set) var discoveryStatusText: String = "Idle" + @Published private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = [] private let discovery = BridgeDiscoveryModel() private weak var appModel: NodeAppModel? @@ -19,6 +20,8 @@ final class BridgeConnectionController: ObservableObject { self.appModel = appModel BridgeSettingsStore.bootstrapPersistence() + self.discovery.setDebugLoggingEnabled( + UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs")) self.discovery.$bridges .sink { [weak self] newValue in @@ -32,11 +35,18 @@ final class BridgeConnectionController: ObservableObject { self.discovery.$statusText .assign(to: &self.$discoveryStatusText) + self.discovery.$debugLog + .assign(to: &self.$discoveryDebugLog) + if startDiscovery { self.discovery.start() } } + func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) { + self.discovery.setDebugLoggingEnabled(enabled) + } + func setScenePhase(_ phase: ScenePhase) { switch phase { case .background: diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift new file mode 100644 index 000000000..46f690393 --- /dev/null +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryDebugLogView.swift @@ -0,0 +1,69 @@ +import SwiftUI +import UIKit + +struct BridgeDiscoveryDebugLogView: View { + @EnvironmentObject private var bridgeController: BridgeConnectionController + @AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false + + var body: some View { + List { + if !self.debugLogsEnabled { + Text("Enable “Discovery Debug Logs” to start collecting events.") + .foregroundStyle(.secondary) + } + + if self.bridgeController.discoveryDebugLog.isEmpty { + Text("No log entries yet.") + .foregroundStyle(.secondary) + } else { + ForEach(self.bridgeController.discoveryDebugLog) { entry in + VStack(alignment: .leading, spacing: 2) { + Text(Self.formatTime(entry.ts)) + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.message) + .font(.callout) + .textSelection(.enabled) + } + .padding(.vertical, 4) + } + } + } + .navigationTitle("Discovery Logs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Copy") { + UIPasteboard.general.string = self.formattedLog() + } + .disabled(self.bridgeController.discoveryDebugLog.isEmpty) + } + } + } + + private func formattedLog() -> String { + self.bridgeController.discoveryDebugLog + .map { "\(Self.formatISO($0.ts)) \($0.message)" } + .joined(separator: "\n") + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + }() + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static func formatTime(_ date: Date) -> String { + self.timeFormatter.string(from: date) + } + + private static func formatISO(_ date: Date) -> String { + self.isoFormatter.string(from: date) + } +} + diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index 96b5f2022..1bffbf878 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -4,6 +4,12 @@ import Network @MainActor final class BridgeDiscoveryModel: ObservableObject { + struct DebugLogEntry: Identifiable, Equatable { + var id = UUID() + var ts: Date + var message: String + } + struct DiscoveredBridge: Identifiable, Equatable { var id: String { self.stableID } var name: String @@ -14,11 +20,26 @@ final class BridgeDiscoveryModel: ObservableObject { @Published var bridges: [DiscoveredBridge] = [] @Published var statusText: String = "Idle" + @Published private(set) var debugLog: [DebugLogEntry] = [] private var browser: NWBrowser? + private var debugLoggingEnabled = false + private var lastStableIDs = Set() + + func setDebugLoggingEnabled(_ enabled: Bool) { + let wasEnabled = self.debugLoggingEnabled + self.debugLoggingEnabled = enabled + if !enabled { + self.debugLog = [] + } else if !wasEnabled { + self.appendDebugLog("debug logging enabled") + self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)") + } + } func start() { if self.browser != nil { return } + self.appendDebugLog("start()") let params = NWParameters.tcp params.includePeerToPeer = true let browser = NWBrowser( @@ -31,16 +52,25 @@ final class BridgeDiscoveryModel: ObservableObject { switch state { case .setup: self.statusText = "Setup" + self.appendDebugLog("state: setup") case .ready: self.statusText = "Searching…" + self.appendDebugLog("state: ready") case let .failed(err): self.statusText = "Failed: \(err)" + self.appendDebugLog("state: failed (\(err))") + self.browser?.cancel() + self.browser = nil case .cancelled: self.statusText = "Stopped" + self.appendDebugLog("state: cancelled") + self.browser = nil case let .waiting(err): self.statusText = "Waiting: \(err)" + self.appendDebugLog("state: waiting (\(err))") @unknown default: self.statusText = "Unknown" + self.appendDebugLog("state: unknown") } } } @@ -48,7 +78,7 @@ final class BridgeDiscoveryModel: ObservableObject { browser.browseResultsChangedHandler = { [weak self] results, _ in Task { @MainActor in guard let self else { return } - self.bridges = results.compactMap { result -> DiscoveredBridge? in + let next = results.compactMap { result -> DiscoveredBridge? in switch result.endpoint { case let .service(name, _, _, _): let decodedName = BonjourEscapes.decode(name) @@ -67,6 +97,16 @@ final class BridgeDiscoveryModel: ObservableObject { } } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + let nextIDs = Set(next.map(\.stableID)) + let added = nextIDs.subtracting(self.lastStableIDs) + let removed = self.lastStableIDs.subtracting(nextIDs) + if !added.isEmpty || !removed.isEmpty { + self.appendDebugLog( + "results: total=\(next.count) added=\(added.count) removed=\(removed.count)") + } + self.lastStableIDs = nextIDs + self.bridges = next } } @@ -75,12 +115,21 @@ final class BridgeDiscoveryModel: ObservableObject { } func stop() { + self.appendDebugLog("stop()") self.browser?.cancel() self.browser = nil self.bridges = [] self.statusText = "Stopped" } + private func appendDebugLog(_ message: String) { + guard self.debugLoggingEnabled else { return } + self.debugLog.append(DebugLogEntry(ts: Date(), message: message)) + if self.debugLog.count > 200 { + self.debugLog.removeFirst(self.debugLog.count - 200) + } + } + private static func prettifyInstanceName(_ decodedName: String) -> String { let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "") diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 5c2a1b8e3..ec4c01491 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -24,6 +24,7 @@ struct SettingsTab: View { @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false @AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 + @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @StateObject private var connectStatus = ConnectStatusStore() @State private var connectingBridgeID: String? @State private var localIPAddress: String? @@ -151,6 +152,15 @@ struct SettingsTab: View { + "The bridge runs on the gateway (default port 18790).") .font(.footnote) .foregroundStyle(.secondary) + + Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) + .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in + self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue) + } + + NavigationLink("Discovery Logs") { + BridgeDiscoveryDebugLogView() + } } } }