Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Packages/Flextunnel/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
71 changes: 64 additions & 7 deletions Sources/FlextunnelApp/ConnPathSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion Sources/FlextunnelApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}

Expand Down
50 changes: 43 additions & 7 deletions Sources/FlextunnelApp/ProxyController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -596,23 +621,34 @@ 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,
kind: .init(token: entry["kind"] as? String),
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 {
Expand Down
Loading