diff --git a/Packages/Flextunnel/Package.swift b/Packages/Flextunnel/Package.swift index 3f06210..00d010d 100644 --- a/Packages/Flextunnel/Package.swift +++ b/Packages/Flextunnel/Package.swift @@ -26,8 +26,8 @@ func localBinaryTarget() -> Target? { let binaryTarget = localBinaryTarget() ?? .binaryTarget( name: "libflextunnel", - url: "https://github.com/flexaccessdev/flextunnel/releases/download/v0.0.55/libflextunnel-ios.xcframework.zip", - checksum: "01e3ec4a02cf94d71151dae914daad73139de3145b04472c8e6f707216b47142" + url: "https://github.com/flexaccessdev/flextunnel/releases/download/v0.0.56/libflextunnel-ios.xcframework.zip", + checksum: "63162d5c4ede9bd12a37f3671dc4e60710c5c6bcb1d6f95c8bbee06323859546" ) let package = Package( diff --git a/Sources/FlextunnelApp/ConnPathSheet.swift b/Sources/FlextunnelApp/ConnPathSheet.swift index f33c3ca..cc20994 100644 --- a/Sources/FlextunnelApp/ConnPathSheet.swift +++ b/Sources/FlextunnelApp/ConnPathSheet.swift @@ -10,28 +10,41 @@ import SwiftUI /// The snapshot is captured on appear and re-captured by Refresh; like the /// desktop modal it is a point-in-time check, not a live field. struct ConnPathSheet: View { - /// Snapshots the live paths right now (`ProxyController.queryConnPath()`). - let query: () -> [ProxyController.ConnPath] + /// Snapshots the live paths + custom-relay health right now + /// (`ProxyController.queryConnPath()`). + let query: () -> ProxyController.ConnectionSnapshot @Environment(\.dismiss) private var dismiss - @State private var paths: [ProxyController.ConnPath] = [] + @State private var snapshot = ProxyController.ConnectionSnapshot() var body: some View { NavigationStack { List { Section { - if paths.isEmpty { + if snapshot.paths.isEmpty { Text("No path yet — still establishing. Close this and try again in a moment.") .font(.footnote) .foregroundStyle(.secondary) } else { - ForEach(paths) { path in + ForEach(snapshot.paths) { path in ConnPathRow(path: path) } } } footer: { Text("Snapshot taken just now — how this session reaches the server. Direct paths are peer-to-peer; relay paths hop through an iroh relay.") } + + if !snapshot.customRelays.isEmpty { + Section { + ForEach(snapshot.customRelays) { relay in + CustomRelayRow(relay: relay) + } + } header: { + Text("Custom relays") + } footer: { + Text("Health is a one-shot check of each relay's /healthz endpoint. It confirms the relay is reachable, not that a relay auth token is accepted.") + } + } } .navigationTitle("Connection path") .navigationBarTitleDisplayMode(.inline) @@ -41,19 +54,63 @@ struct ConnPathSheet: View { } ToolbarItem(placement: .primaryAction) { Button { - paths = query() + snapshot = query() } label: { Image(systemName: "arrow.clockwise") } .accessibilityLabel("Refresh") } } - .onAppear { paths = query() } + .onAppear { snapshot = query() } } .presentationDetents([.medium, .large]) } } +/// One custom-relay row: its URL and a colored health status from the on-demand +/// `/healthz` probe (`working` = up / down / unknown). +private struct CustomRelayRow: View { + let relay: ProxyController.CustomRelay + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 10) { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + Text(relay.url) + .font(.system(.footnote, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + Text(statusText) + .font(.caption) + .foregroundStyle(dotColor) + } + .padding(.vertical, 2) + } + + private var dotColor: Color { + switch relay.working { + case .some(true): return .green + case .some(false): return .red + case .none: return .gray + } + } + + private var statusText: String { + switch relay.working { + case .some(true): return "Working" + case .some(false): + if let error = relay.error, !error.isEmpty { + return "Not working — \(error)" + } + return "Not working" + case .none: return "Status unavailable" + } + } +} + /// One path row: a transport-colored dot, the human-readable path line, and an /// "active" pill on the path iroh currently routes over. private struct ConnPathRow: View { diff --git a/Sources/FlextunnelApp/ContentView.swift b/Sources/FlextunnelApp/ContentView.swift index 44749ae..13e0830 100644 --- a/Sources/FlextunnelApp/ContentView.swift +++ b/Sources/FlextunnelApp/ContentView.swift @@ -59,6 +59,7 @@ struct ContentView: View { @AppStorage("lastServerNodeID") private var serverNodeID = "" @State private var authToken = "" @State private var relayURLs = "" + @State private var relayAuthToken = "" // Browser mode's loopback SOCKS5 port. Forwarding-only mode has no SOCKS5 // listener. This is picked at random (private/dynamic range) once per session and // held until the session exits, so it stays stable across the core's own @@ -114,6 +115,10 @@ struct ContentView: View { TextField("", text: $relayURLs) .autocorrectionDisabled().textInputAutocapitalization(.never) } + LabeledField("Relay auth token", hint: "optional, custom relays only") { + SecureField("", text: $relayAuthToken) + .autocorrectionDisabled().textInputAutocapitalization(.never) + } } // Both modes share the config above; pick one, and the single @@ -308,7 +313,8 @@ struct ContentView: View { // Forwarding-only sessions have no SOCKS listener. Browser mode's // random port is held across reconnects. socksPort: sessionMode == .browser ? (browserSessionPort ?? 0) : nil, - relayURLs: splitCSV(relayURLs) + relayURLs: splitCSV(relayURLs), + relayAuthToken: relayAuthToken ) } diff --git a/Sources/FlextunnelApp/ProxyController.swift b/Sources/FlextunnelApp/ProxyController.swift index f9d3b5f..ac4caa3 100644 --- a/Sources/FlextunnelApp/ProxyController.swift +++ b/Sources/FlextunnelApp/ProxyController.swift @@ -110,6 +110,9 @@ final class ProxyController: ObservableObject { /// session with no SOCKS5 listener; `0` requests an ephemeral port. var socksPort: UInt16? var relayURLs: [String] + /// Shared bearer token sent to every custom relay's WebSocket upgrade. + /// Empty means none; only valid with custom `relayURLs`. + var relayAuthToken: String } struct ConnectionSummary { @@ -199,6 +202,26 @@ final class ProxyController: ObservableObject { } } + /// Health of one configured custom relay, from the on-demand `/healthz` + /// probe the core runs when a snapshot is requested. `working` is `true` on a + /// 2xx, `false` when unreachable/timed-out/non-2xx, and `nil` if the check + /// could not run. `/healthz` is unauthenticated: it confirms the relay is up, + /// not that a relay auth token is accepted. + struct CustomRelay: Identifiable { + let url: String + var working: Bool? + var error: String? + + var id: String { url } + } + + /// One on-demand connection snapshot: the live iroh path(s) plus custom-relay + /// health. Empty on both counts while the tunnel link is down. + struct ConnectionSnapshot { + var paths: [ConnPath] = [] + var customRelays: [CustomRelay] = [] + } + /// A reverse-routing (agent) alias plus the backing agent's live connection /// status as the core reports it: `connected`, `disconnected`, or `unknown` /// (the last when the tunnel is down or the heartbeat-fed view is stale). @@ -261,11 +284,13 @@ final class ProxyController: ObservableObject { lastError = nil teardownHandle() // tear down any previous handle first + let relayAuthToken = s.relayAuthToken.trimmingCharacters(in: .whitespacesAndNewlines) let configDict: [String: Any] = [ "server_node_id": s.serverNodeID, "auth_token": s.authToken, "socks_port": s.socksPort.map { Int($0) } ?? NSNull(), "relay_urls": s.relayURLs, + "relay_auth_token": relayAuthToken.isEmpty ? NSNull() : relayAuthToken, ] guard let data = try? JSONSerialization.data(withJSONObject: configDict), @@ -596,16 +621,17 @@ final class ProxyController: ObservableObject { /// readout (relay/direct) for the "connection path" status sheet, mirroring /// the desktop CTA. Empty while the tunnel link is down (the core routes over /// no path then), so callers only offer it while `tunnelConnected`. - func queryConnPath() -> [ConnPath] { - guard let handle else { return [] } + func queryConnPath() -> ConnectionSnapshot { + guard let handle else { return ConnectionSnapshot() } var buf = [CChar](repeating: 0, count: 8 * 1024) - guard flextunnel_conn_path(handle, &buf, buf.count) == 1 else { return [] } + guard flextunnel_conn_path(handle, &buf, buf.count) == 1 else { return ConnectionSnapshot() } guard let data = String(cString: buf).data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let paths = obj["paths"] as? [[String: Any]] - else { return [] } - return paths.enumerated().compactMap { index, entry in + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return ConnectionSnapshot() } + + let paths = (obj["paths"] as? [[String: Any]] ?? []).enumerated().compactMap { + index, entry -> ConnPath? in guard let display = entry["display"] as? String else { return nil } return ConnPath( id: index, @@ -613,6 +639,16 @@ final class ProxyController: ObservableObject { display: display, selected: entry["selected"] as? Bool ?? false) } + let customRelays = (obj["custom_relays"] as? [[String: Any]] ?? []).compactMap { + entry -> CustomRelay? in + guard let url = entry["url"] as? String else { return nil } + // `working` is a JSON bool or null; `NSNull`/absent both map to nil. + return CustomRelay( + url: url, + working: entry["working"] as? Bool, + error: entry["error"] as? String) + } + return ConnectionSnapshot(paths: paths, customRelays: customRelays) } deinit {