Files
Zoltan Papp 3f67c8b397 Fix route status indicator for dynamic (DNS) routes (#117)
* fix(iOS): correct route status indicator for dynamic (DNS) routes

The status indicator stayed yellow forever for dynamic routes because
the previous logic searched for the literal "invalid Prefix" sentinel
inside domain strings, which never matched.

Match the Android client's logic: a dynamic route is "connected" (green)
when any resolved IP for one of its domains appears in a Connected peer's
route list. Compare addresses with the CIDR mask stripped, since peer
routes carry /32 suffixes while resolved IPs do not.

Switch the bridge DTO from a comma-joined ResolvedIPs string to a
structured [String] list (mirrors Android's ResolvedIPs collection in
client/android/network_domains.go), so consumers no longer depend on
the Go-side string formatting.

Bumps netbird-core submodule.

* debug(iOS): log status indicator decisions in RouteCard

Adds verbose NSLog output explaining why each route card resolves to
gray, yellow, or green. To be reverted once the dynamic-route status
indicator regression is diagnosed.

* debug(iOS): route status indicator decisions to AppLogger

Replace NSLog with AppLogger.shared.log so traces land in the shared
swift-log.log file. Deduplicate per-route decisions so SwiftUI
re-renders don't flood the rotating log.

* fix(iOS): drop "invalid Prefix" sentinel, align with Android route logic

The bridge now exposes Domains.SafeString() as the Network value for
dynamic (DNS) routes, matching the Android client. That string also
appears in peer.routes (it's what dynamic.Route.String() returns), so
a single peer.routes.contains(route.network) check works for both
static and dynamic routes — no sentinel branching needed.

Replace "invalid Prefix" checks with route.domains presence checks in
the status indicator, route display text, and tooltip detail view, on
both iOS and tvOS.

Bumps netbird-core submodule.

* chore(iOS): remove RouteCard status-indicator debug logging

Diagnosis is complete; the verbose AppLogger output and dedup cache
in statusIndicatorColor are no longer needed.

* chore(iOS): point netbird-core submodule at merged upstream commit

The bridge change (structured ResolvedIPs collection + dynamic-route
Network exposure) has landed on the netbird-core main branch as
f23aaa9ae. Replace the local feature-branch SHA with the merged one.
2026-05-07 13:19:56 +02:00

209 lines
6.8 KiB
Swift

//
// RouteCard.swift
// NetBirdiOS
//
// Created by Pascal Fischer on 29.06.24.
//
import SwiftUI
struct RouteCard: View {
@ObservedObject var route: RoutesSelectionInfo
@Binding var selectedRouteId: UUID?
@State var orientationTop: Bool
@ObservedObject var peerViewModel: PeerViewModel
@ObservedObject var routeViewModel: RoutesViewModel
@State private var tooltipSize: CGSize = .zero
@GestureState private var isPressing: Bool = false
var body: some View {
HStack {
HStack {
RoundedRectangle(cornerRadius: 8)
.fill(statusIndicatorColor)
.frame(width: 8, height: 40)
VStack(alignment: .leading) {
Text(route.name)
.foregroundColor(Color("TextPeerCard"))
if let network = route.network, network.contains("0.0.0.0/0") {
Image("direction-sign")
}
}
.padding(.leading, 5)
Spacer()
Text(routeDisplayText)
.foregroundColor(Color("TextPeerCard"))
.padding(.leading, 3)
}
.padding()
.background(Color("BgPeerCard"))
.cornerRadius(8)
.simultaneousGesture(
TapGesture()
.updating($isPressing) { _, gestureState, _ in
gestureState = true
}
.onEnded {
if !isPressing {
withAnimation {
toggleRouteSelection()
}
}
}
)
Toggle("", isOn: Binding(
get: { route.selected },
set: { newValue in
routeViewModel.toggleSelected(for: route.id)
}
))
.labelsHidden()
.toggleStyle(SwitchToggleStyle(tint: .orange))
.onChange(of: route.selected) { newValue in
newValue ? routeViewModel.selectRoute(route: route) : routeViewModel.deselectRoute(route: route)
}
.padding(.trailing, 15)
}
.background(Color("BgPeerCard"))
.cornerRadius(8)
.overlay(
GeometryReader { parentGeometry in
ZStack {
if selectedRouteId == route.id {
RouteTooltipView(route: route, orientationTop: orientationTop, selectedRouteId: $selectedRouteId)
.background(GeometryReader { tooltipGeometry in
Color.clear
.onAppear {
tooltipSize = tooltipGeometry.size
}
})
.position(
x: parentGeometry.size.width / 2,
y: orientationTop
? parentGeometry.size.height - (tooltipSize.height / 2) - 50
: (tooltipSize.height / 2) + 50
)
.opacity(1)
}
}
.frame(width: parentGeometry.size.width, height: parentGeometry.size.height, alignment: .center)
},
alignment: .center
)
}
private var statusIndicatorColor: Color {
guard route.selected else { return Color.gray.opacity(0.5) }
let connectedPeerRoutes = peerViewModel.peerInfo
.filter { $0.connStatus == "Connected" }
.flatMap { $0.routes }
if let network = route.network, connectedPeerRoutes.contains(network) {
return Color.green
}
let resolvedIPs = (route.domains ?? []).flatMap { $0.resolvedIPs }
if resolvedIPs.contains(where: connectedPeerRoutes.contains) {
return Color.green
}
return Color.yellow
}
private var routeDisplayText: String {
if let domains = route.domains, !domains.isEmpty {
if domains.count > 2 {
return "\(domains.count) Domains"
}
return domains.map { $0.domain }.joined(separator: ", ")
}
return route.network ?? "Unknown"
}
private func toggleRouteSelection() {
routeViewModel.selectedRouteId = routeViewModel.selectedRouteId == route.id ? nil : route.id
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct RouteTooltipView: View {
@ObservedObject var route: RoutesSelectionInfo
@State var orientationTop: Bool
@Binding var selectedRouteId: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(route.name)
.font(.headline)
Divider()
detailInfo()
}
.padding()
.background(Color(UIColor.systemGray6))
.cornerRadius(5)
.shadow(radius: 5)
.frame(width: UIScreen.main.bounds.width * 0.8)
.overlay(
Triangle()
.fill(Color(UIColor.systemGray6))
.frame(width: 20, height: 10)
.rotationEffect(.degrees(orientationTop ? 180 : 0 ))
.offset(x: 0, y: orientationTop ? 10 : -10), alignment: orientationTop ? .bottom : .top
)
.transition(.identity)
}
@ViewBuilder
func detailInfo() -> some View {
Group {
if let domains = route.domains, !domains.isEmpty {
ForEach(domains, id: \.self) { domain in
detailRow(label: domain.domain, value: domain.resolvedIPs.joined(separator: ", "))
}
} else {
detailRow(label: "Network", value: route.network ?? "")
}
}
.font(.footnote)
}
@ViewBuilder
func detailRow(label: String, value: String) -> some View {
HStack {
Text("\(label):").bold()
Spacer()
Text(value)
.multilineTextAlignment(.leading)
.foregroundColor(.gray)
.fixedSize(horizontal: false, vertical: true)
}
}
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
return path
}
}
}