Skip to content

Commit 371bb71

Browse files
torlando-agent[bot]claudetorlando-agent[bot]
authored
feat(InterfaceManagement): add TCP client community-server wizard (#64)
* feat(InterfaceManagement): add TCP client community-server wizard Mirrors Android Columba's 2-step TCP client wizard at the post-onboarding add-interface surface: server selection (bootstrap/community/custom) → review & configure. Routes Settings → Network Interfaces → + → TCP Client through the wizard instead of the blank manual entry sheet, and reroutes edit-existing for TCP entries to the same flow with pre-filled values. Scoped to the fields TCPClientConfig already supports (host, port, networkName, passphrase). Bootstrap-only flag and SOCKS proxy are deferred. Closes #51 Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com> * fix(TCPClientWizard): mirror android server list, drop bootstrap split Addresses PR review comments: #64 (comment) #64 (comment) Replace the iOS community-server directory with the canonical Android list at app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt. Removes decommissioned / non-existent entries (RNS Amsterdam, RNS BetweenTheBorders, RNS Frankfurt, i2p Reticulum, Reticulum Ireland, TheHub, Kosciuszko, Reticulum Ireland v2, RNS Roaming) and adds the servers that are actually present on the network. i2p is dropped entirely because iOS has no i2p transport. Also collapse the "Bootstrap Servers" / "Community Servers" split in TCPClientWizard into a single "Community Servers" section, since Reticulum-Swift does not yet implement bootstrap-interface mode and splitting them would mislead users into expecting bootstrap behavior. The isBootstrap flag on the data model is preserved so the Android table stays mirrorable. Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com> * chore(greptile): iteration 1 — applied 4, rejected 0 Co-Authored-By: Claude claude-opus-4-7 <noreply@anthropic.com> * fix(TcpCommunityServer): remove unwanted servers from wizard list The following entries should not be surfaced in the on-device wizard: - interloper node + interloper node (Tor) - Jon's Node - Quortal TCP Node - R-Net TCP - RNS bnZ-NODE01, RNS COMSEC-RD, RNS HAM RADIO - RNS Testnet StoppedCold - RNS_Transport_US-East - Tidudanka.com Surviving list: 3 bootstrap-class (Beleth RNS Hub, Quad4 TCP Node 1, FireZen) + 7 community (g00n.cloud Hub, noDNS1, noDNS2, NomadNode SEAsia TCP, 0rbit-Net, Quad4 TCP Node 2, SparkN0de). NOTE: the file's docstring claims this list mirrors Android's `TcpCommunityServer.kt`. Pruning here breaks that mirror; a follow-up PR should make the equivalent removal on the Android side, OR the "keep in sync" claim should be relaxed to "originally derived from." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: torlando-agent[bot] <281092095+torlando-agent[bot]@users.noreply.github.com> Co-authored-by: Claude claude-opus-4-7 <noreply@anthropic.com> Co-authored-by: torlando-agent[bot] <torlando-agent@noreply.github.com>
1 parent 7e82132 commit 371bb71

7 files changed

Lines changed: 951 additions & 15 deletions

File tree

Columba.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@
123123
082B /* MicronRenderContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F083 /* MicronRenderContainer.swift */; };
124124
083B /* MonospaceLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F084 /* MonospaceLineView.swift */; };
125125
084B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F085 /* ZoomableScrollView.swift */; };
126+
086B /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F086 /* TCPClientWizardViewModel.swift */; };
127+
087B /* TCPClientWizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F087 /* TCPClientWizard.swift */; };
128+
T006 /* TCPClientWizardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT06 /* TCPClientWizardViewModelTests.swift */; };
126129
T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; };
127130
TAA0 /* AutoAnnouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTAA /* AutoAnnouncePolicyTests.swift */; };
128131
TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTPC /* PeerChildInterfaceRegistryTests.swift */; };
@@ -262,6 +265,9 @@
262265
F083 /* MicronRenderContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronRenderContainer.swift; sourceTree = "<group>"; };
263266
F084 /* MonospaceLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonospaceLineView.swift; sourceTree = "<group>"; };
264267
F085 /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; };
268+
F086 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = "<group>"; };
269+
F087 /* TCPClientWizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizard.swift; sourceTree = "<group>"; };
270+
FT06 /* TCPClientWizardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModelTests.swift; sourceTree = "<group>"; };
265271
F07B /* Config/Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Signing.xcconfig; sourceTree = SOURCE_ROOT; };
266272
F07C /* Config/LocalSigning.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/LocalSigning.xcconfig.example; sourceTree = SOURCE_ROOT; };
267273
FE01 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
@@ -466,6 +472,7 @@
466472
F066 /* AppearanceCard.swift */,
467473
F067 /* CustomThemeEditorView.swift */,
468474
F071 /* BLEConnectionsView.swift */,
475+
F087 /* TCPClientWizard.swift */,
469476
GRNW /* RNodeWizard */,
470477
);
471478
path = Settings;
@@ -552,6 +559,7 @@
552559
F05A /* RNodeWizardViewModel.swift */,
553560
F05F /* MigrationViewModel.swift */,
554561
F080 /* NomadNetBrowserViewModel.swift */,
562+
F086 /* TCPClientWizardViewModel.swift */,
555563
);
556564
path = ViewModels;
557565
sourceTree = "<group>";
@@ -576,6 +584,7 @@
576584
FTAA /* AutoAnnouncePolicyTests.swift */,
577585
FTPC /* PeerChildInterfaceRegistryTests.swift */,
578586
FT05 /* MapStyleURLTests.swift */,
587+
FT06 /* TCPClientWizardViewModelTests.swift */,
579588
);
580589
path = Tests/ColumbaAppTests;
581590
sourceTree = "<group>";
@@ -748,6 +757,7 @@
748757
TAA0 /* AutoAnnouncePolicyTests.swift in Sources */,
749758
TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */,
750759
T005 /* MapStyleURLTests.swift in Sources */,
760+
T006 /* TCPClientWizardViewModelTests.swift in Sources */,
751761
);
752762
runOnlyForDeploymentPostprocessing = 0;
753763
};
@@ -869,6 +879,8 @@
869879
082B /* MicronRenderContainer.swift in Sources */,
870880
083B /* MonospaceLineView.swift in Sources */,
871881
084B /* ZoomableScrollView.swift in Sources */,
882+
086B /* TCPClientWizardViewModel.swift in Sources */,
883+
087B /* TCPClientWizard.swift in Sources */,
872884
);
873885
runOnlyForDeploymentPostprocessing = 0;
874886
};

Sources/ColumbaApp/Models/TcpCommunityServer.swift

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,25 @@ struct TcpCommunityServer: Identifiable {
2525
extension TcpCommunityServer {
2626
/// Curated list of public Reticulum transport nodes.
2727
///
28-
/// Sourced from Android Columba's `TcpCommunityServers.kt`.
29-
/// Bootstrap servers are preferred for first-time connections.
28+
/// Sourced from Android Columba's `TcpCommunityServer.kt`. Keep this list
29+
/// in sync with `app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt`.
30+
/// Up-to-date community directories: directory.rns.recipes, rmap.world.
3031
static let servers: [TcpCommunityServer] = [
31-
// Bootstrap servers
32+
// Bootstrap-class servers (well-established, reliable nodes).
33+
// Reticulum-Swift does not yet support the bootstrap interface mode,
34+
// so the iOS UI surfaces these alongside other community servers.
3235
TcpCommunityServer(name: "Beleth RNS Hub", host: "rns.beleth.net", port: 4242, isBootstrap: true),
33-
TcpCommunityServer(name: "Quad4 RNS", host: "rns.quad4.io", port: 4242, isBootstrap: true),
34-
TcpCommunityServer(name: "FireZen Hub", host: "reticulum.firezen.xyz", port: 4242, isBootstrap: true),
36+
TcpCommunityServer(name: "Quad4 TCP Node 1", host: "rns.quad4.io", port: 4242, isBootstrap: true),
37+
TcpCommunityServer(name: "FireZen", host: "firezen.com", port: 4242, isBootstrap: true),
3538

3639
// Community servers
37-
TcpCommunityServer(name: "RNS Amsterdam", host: "amsterdam.connect.reticulum.network", port: 4965, isBootstrap: false),
38-
TcpCommunityServer(name: "RNS BetweenTheBorders", host: "betweentheborders.com", port: 4242, isBootstrap: false),
39-
TcpCommunityServer(name: "RNS Frankfurt", host: "frankfurt.connect.reticulum.network", port: 5377, isBootstrap: false),
40-
TcpCommunityServer(name: "i2p Reticulum", host: "uxg5a4t3pnif7zoo43fkdrhgamlbfcovgsrzjakqab3pxjfqwdcq.b32.i2p", port: 5001, isBootstrap: false),
41-
TcpCommunityServer(name: "Reticulum Ireland", host: "reticulum.liamcottle.net", port: 4242, isBootstrap: false),
42-
TcpCommunityServer(name: "TheHub", host: "thehub.duckdns.org", port: 4242, isBootstrap: false),
43-
TcpCommunityServer(name: "Kosciuszko", host: "kosciuszko.au.int.rns.directory", port: 9696, isBootstrap: false),
44-
TcpCommunityServer(name: "Reticulum Ireland v2", host: "reticulum.liamcottle.net", port: 4343, isBootstrap: false),
45-
TcpCommunityServer(name: "RNS Roaming", host: "roaming.int.rns.directory", port: 9697, isBootstrap: false),
40+
TcpCommunityServer(name: "g00n.cloud Hub", host: "dfw.us.g00n.cloud", port: 6969, isBootstrap: false),
41+
TcpCommunityServer(name: "noDNS1", host: "202.61.243.41", port: 4965, isBootstrap: false),
42+
TcpCommunityServer(name: "noDNS2", host: "193.26.158.230", port: 4965, isBootstrap: false),
43+
TcpCommunityServer(name: "NomadNode SEAsia TCP", host: "rns.jaykayenn.net", port: 4242, isBootstrap: false),
44+
TcpCommunityServer(name: "0rbit-Net", host: "93.95.227.8", port: 49952, isBootstrap: false),
45+
TcpCommunityServer(name: "Quad4 TCP Node 2", host: "rns2.quad4.io", port: 4242, isBootstrap: false),
46+
TcpCommunityServer(name: "SparkN0de", host: "aspark.uber.space", port: 44860, isBootstrap: false),
4647
]
4748

4849
/// Default server for first-time connections.

Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ private let logger = Logger(subsystem: "network.columba.Columba", category: "Int
2121
/// with InterfaceRepository for persistence.
2222
@available(iOS 17.0, macOS 14.0, *)
2323
@Observable
24-
public final class InterfaceManagementViewModel {
24+
public final class InterfaceManagementViewModel: TCPClientWizardSaveSink {
2525

2626
// MARK: - Dependencies
2727

@@ -68,6 +68,9 @@ public final class InterfaceManagementViewModel {
6868
/// Whether the RNode wizard is shown (uses fullScreenCover to survive BLE pairing dialog)
6969
public var showRNodeWizard: Bool = false
7070

71+
/// Whether the TCP client wizard is shown (community server picker → review/configure)
72+
public var showTCPWizard: Bool = false
73+
7174
/// Interface being edited (nil for new interface)
7275
public var editingInterface: InterfaceEntity?
7376

@@ -215,6 +218,8 @@ public final class InterfaceManagementViewModel {
215218

216219
if type == .rnode {
217220
showRNodeWizard = true
221+
} else if type == .tcpClient {
222+
showTCPWizard = true
218223
} else {
219224
showConfigSheet = true
220225
}
@@ -226,6 +231,8 @@ public final class InterfaceManagementViewModel {
226231
populateConfigForm(from: interface)
227232
if interface.type == .rnode {
228233
showRNodeWizard = true
234+
} else if interface.type == .tcpClient {
235+
showTCPWizard = true
229236
} else {
230237
showConfigSheet = true
231238
}
@@ -235,6 +242,7 @@ public final class InterfaceManagementViewModel {
235242
public func dismissConfigSheet() {
236243
showConfigSheet = false
237244
showRNodeWizard = false
245+
showTCPWizard = false
238246
editingInterface = nil
239247
resetConfigForm()
240248
}
@@ -280,6 +288,49 @@ public final class InterfaceManagementViewModel {
280288
}
281289
}
282290

291+
/// Save a TCP client interface from the wizard flow.
292+
///
293+
/// Bypasses the form-field validation path (the wizard does its own validation
294+
/// in `canProceed`) and writes directly through the repository, then triggers
295+
/// the standard apply-changes pipeline.
296+
public func saveTCPInterface(
297+
editing: InterfaceEntity?,
298+
name: String,
299+
enabled: Bool,
300+
mode: InterfaceMode,
301+
config: TCPClientConfig
302+
) {
303+
let trimmedName = name.trimmingCharacters(in: .whitespaces)
304+
let interfaceConfig: InterfaceTypeConfig = .tcpClient(config)
305+
306+
if let existing = editing {
307+
var updated = existing
308+
updated.name = trimmedName
309+
updated.enabled = enabled
310+
updated.mode = mode
311+
updated.config = interfaceConfig
312+
repository.updateInterface(updated)
313+
showSuccess("Interface updated")
314+
} else {
315+
let newInterface = InterfaceEntity(
316+
name: trimmedName,
317+
type: .tcpClient,
318+
enabled: enabled,
319+
mode: mode,
320+
config: interfaceConfig
321+
)
322+
repository.addInterface(newInterface)
323+
showSuccess("Interface added")
324+
}
325+
326+
hasPendingChanges = true
327+
dismissConfigSheet()
328+
329+
Task { @MainActor in
330+
await applyChanges()
331+
}
332+
}
333+
283334
// MARK: - Apply Changes
284335

285336
/// Apply pending interface changes to the running network.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//
2+
// TCPClientWizardViewModel.swift
3+
// ColumbaApp
4+
//
5+
// State management for the 2-step TCP client interface configuration wizard.
6+
// Mirrors the Android Columba TcpClientWizardViewModel.
7+
//
8+
9+
import Foundation
10+
import SwiftUI
11+
import ReticulumSwift
12+
13+
// MARK: - Wizard Step
14+
15+
/// Steps in the TCP client configuration wizard.
16+
@available(iOS 17.0, macOS 14.0, *)
17+
enum TCPClientWizardStep: Int, CaseIterable, Identifiable {
18+
case serverSelection = 0
19+
case reviewConfigure = 1
20+
21+
var id: Int { rawValue }
22+
23+
var title: String {
24+
switch self {
25+
case .serverSelection: return "Select Server"
26+
case .reviewConfigure: return "Review & Configure"
27+
}
28+
}
29+
}
30+
31+
// MARK: - Parent Save Sink
32+
33+
/// Minimal protocol the wizard uses to forward a built TCP config to the
34+
/// parent `InterfaceManagementViewModel`. Lets tests stub the parent without
35+
/// pulling in repository / AppServices wiring.
36+
@available(iOS 17.0, macOS 14.0, *)
37+
protocol TCPClientWizardSaveSink: AnyObject {
38+
func saveTCPInterface(
39+
editing: InterfaceEntity?,
40+
name: String,
41+
enabled: Bool,
42+
mode: InterfaceMode,
43+
config: TCPClientConfig
44+
)
45+
}
46+
47+
// MARK: - ViewModel
48+
49+
/// ViewModel for the TCP client configuration wizard.
50+
///
51+
/// Manages step navigation, server selection vs custom mode, edit-mode
52+
/// pre-population, and forwards the built `TCPClientConfig` through a
53+
/// `TCPClientWizardSaveSink` so the existing add/update path on
54+
/// `InterfaceManagementViewModel` stays the single source of persistence.
55+
@available(iOS 17.0, macOS 14.0, *)
56+
@Observable
57+
@MainActor
58+
final class TCPClientWizardViewModel {
59+
60+
// MARK: - Navigation
61+
62+
var currentStep: TCPClientWizardStep = .serverSelection
63+
64+
// MARK: - Step 1: Server Selection
65+
66+
var selectedServer: TcpCommunityServer?
67+
var isCustomMode: Bool = false
68+
69+
// MARK: - Step 2: Review & Configure
70+
71+
var interfaceName: String = ""
72+
var targetHost: String = ""
73+
var targetPort: String = "4242"
74+
var networkName: String = ""
75+
var passphrase: String = ""
76+
var showPassphrase: Bool = false
77+
var mode: InterfaceMode = .full
78+
var enabled: Bool = true
79+
var showAdvanced: Bool = false
80+
81+
// MARK: - Edit Context
82+
83+
/// The interface being edited (nil for create flow).
84+
private(set) var editingInterface: InterfaceEntity?
85+
86+
/// Whether this wizard run is editing an existing interface.
87+
var isEditing: Bool { editingInterface != nil }
88+
89+
// MARK: - Step 1 Actions
90+
91+
/// Pre-fill name/host/port from a community server and clear custom mode.
92+
func selectServer(_ server: TcpCommunityServer) {
93+
selectedServer = server
94+
isCustomMode = false
95+
interfaceName = server.name
96+
targetHost = server.host
97+
targetPort = String(server.port)
98+
}
99+
100+
/// Switch to custom-server mode: clear the selection and blank
101+
/// the name/host/port fields so the user types fresh values in step 2.
102+
func enableCustomMode() {
103+
selectedServer = nil
104+
isCustomMode = true
105+
interfaceName = ""
106+
targetHost = ""
107+
targetPort = ""
108+
}
109+
110+
// MARK: - Edit Pre-population
111+
112+
/// Populate fields from an existing TCP interface.
113+
///
114+
/// If `(host, port)` matches a known `TcpCommunityServer`, that server
115+
/// is selected and the wizard opens at step 1. Otherwise the wizard opens
116+
/// at step 1 in custom mode so the user can confirm or change the entry.
117+
func loadExisting(_ entity: InterfaceEntity) {
118+
guard case .tcpClient(let config) = entity.config else { return }
119+
editingInterface = entity
120+
interfaceName = entity.name
121+
targetHost = config.targetHost
122+
targetPort = String(config.targetPort)
123+
networkName = config.networkName ?? ""
124+
passphrase = config.passphrase ?? ""
125+
mode = entity.mode
126+
enabled = entity.enabled
127+
128+
let match = TcpCommunityServer.servers.first { server in
129+
server.host == config.targetHost && server.port == config.targetPort
130+
}
131+
if let match = match {
132+
selectedServer = match
133+
isCustomMode = false
134+
} else {
135+
selectedServer = nil
136+
isCustomMode = true
137+
}
138+
currentStep = .serverSelection
139+
}
140+
141+
// MARK: - Validation
142+
143+
/// Whether the wizard can advance / save from the given step.
144+
func canProceed(from step: TCPClientWizardStep) -> Bool {
145+
switch step {
146+
case .serverSelection:
147+
return selectedServer != nil || isCustomMode
148+
case .reviewConfigure:
149+
let host = targetHost.trimmingCharacters(in: .whitespaces)
150+
guard !host.isEmpty else { return false }
151+
guard let port = UInt16(targetPort.trimmingCharacters(in: .whitespaces)),
152+
port > 0 else {
153+
return false
154+
}
155+
let trimmedName = interfaceName.trimmingCharacters(in: .whitespaces)
156+
return !trimmedName.isEmpty
157+
}
158+
}
159+
160+
// MARK: - Step Navigation
161+
162+
func goToReview() {
163+
currentStep = .reviewConfigure
164+
}
165+
166+
func goToServerSelection() {
167+
currentStep = .serverSelection
168+
}
169+
170+
// MARK: - Save
171+
172+
/// Build the `TCPClientConfig` and forward it to the parent through the
173+
/// save sink. Persistence + apply-changes stay on the parent.
174+
func save(into sink: TCPClientWizardSaveSink) {
175+
let trimmedHost = targetHost.trimmingCharacters(in: .whitespaces)
176+
let port = UInt16(targetPort.trimmingCharacters(in: .whitespaces)) ?? 4242
177+
let trimmedNetwork = networkName.trimmingCharacters(in: .whitespaces)
178+
let trimmedPassphrase = passphrase.trimmingCharacters(in: .whitespaces)
179+
180+
let config = TCPClientConfig(
181+
targetHost: trimmedHost,
182+
targetPort: port,
183+
networkName: trimmedNetwork.isEmpty ? nil : trimmedNetwork,
184+
passphrase: trimmedPassphrase.isEmpty ? nil : trimmedPassphrase
185+
)
186+
187+
sink.saveTCPInterface(
188+
editing: editingInterface,
189+
name: interfaceName.trimmingCharacters(in: .whitespaces),
190+
enabled: enabled,
191+
mode: mode,
192+
config: config
193+
)
194+
}
195+
}

0 commit comments

Comments
 (0)