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
12 changes: 12 additions & 0 deletions Columba.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
082B /* MicronRenderContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F083 /* MicronRenderContainer.swift */; };
083B /* MonospaceLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F084 /* MonospaceLineView.swift */; };
084B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F085 /* ZoomableScrollView.swift */; };
086B /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F086 /* TCPClientWizardViewModel.swift */; };
087B /* TCPClientWizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F087 /* TCPClientWizard.swift */; };
T006 /* TCPClientWizardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT06 /* TCPClientWizardViewModelTests.swift */; };
T003 /* MicronParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FT03 /* MicronParserTests.swift */; };
TAA0 /* AutoAnnouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTAA /* AutoAnnouncePolicyTests.swift */; };
TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FTPC /* PeerChildInterfaceRegistryTests.swift */; };
Expand Down Expand Up @@ -262,6 +265,9 @@
F083 /* MicronRenderContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicronRenderContainer.swift; sourceTree = "<group>"; };
F084 /* MonospaceLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonospaceLineView.swift; sourceTree = "<group>"; };
F085 /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; };
F086 /* TCPClientWizardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModel.swift; sourceTree = "<group>"; };
F087 /* TCPClientWizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizard.swift; sourceTree = "<group>"; };
FT06 /* TCPClientWizardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPClientWizardViewModelTests.swift; sourceTree = "<group>"; };
F07B /* Config/Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Signing.xcconfig; sourceTree = SOURCE_ROOT; };
F07C /* Config/LocalSigning.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/LocalSigning.xcconfig.example; sourceTree = SOURCE_ROOT; };
FE01 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -466,6 +472,7 @@
F066 /* AppearanceCard.swift */,
F067 /* CustomThemeEditorView.swift */,
F071 /* BLEConnectionsView.swift */,
F087 /* TCPClientWizard.swift */,
GRNW /* RNodeWizard */,
);
path = Settings;
Expand Down Expand Up @@ -552,6 +559,7 @@
F05A /* RNodeWizardViewModel.swift */,
F05F /* MigrationViewModel.swift */,
F080 /* NomadNetBrowserViewModel.swift */,
F086 /* TCPClientWizardViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
Expand All @@ -576,6 +584,7 @@
FTAA /* AutoAnnouncePolicyTests.swift */,
FTPC /* PeerChildInterfaceRegistryTests.swift */,
FT05 /* MapStyleURLTests.swift */,
FT06 /* TCPClientWizardViewModelTests.swift */,
);
path = Tests/ColumbaAppTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -748,6 +757,7 @@
TAA0 /* AutoAnnouncePolicyTests.swift in Sources */,
TPCR /* PeerChildInterfaceRegistryTests.swift in Sources */,
T005 /* MapStyleURLTests.swift in Sources */,
T006 /* TCPClientWizardViewModelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -869,6 +879,8 @@
082B /* MicronRenderContainer.swift in Sources */,
083B /* MonospaceLineView.swift in Sources */,
084B /* ZoomableScrollView.swift in Sources */,
086B /* TCPClientWizardViewModel.swift in Sources */,
087B /* TCPClientWizard.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
29 changes: 15 additions & 14 deletions Sources/ColumbaApp/Models/TcpCommunityServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,25 @@ struct TcpCommunityServer: Identifiable {
extension TcpCommunityServer {
/// Curated list of public Reticulum transport nodes.
///
/// Sourced from Android Columba's `TcpCommunityServers.kt`.
/// Bootstrap servers are preferred for first-time connections.
/// Sourced from Android Columba's `TcpCommunityServer.kt`. Keep this list
/// in sync with `app/src/main/java/network/columba/app/data/model/TcpCommunityServer.kt`.
/// Up-to-date community directories: directory.rns.recipes, rmap.world.
static let servers: [TcpCommunityServer] = [
// Bootstrap servers
// Bootstrap-class servers (well-established, reliable nodes).
// Reticulum-Swift does not yet support the bootstrap interface mode,
// so the iOS UI surfaces these alongside other community servers.
TcpCommunityServer(name: "Beleth RNS Hub", host: "rns.beleth.net", port: 4242, isBootstrap: true),
TcpCommunityServer(name: "Quad4 RNS", host: "rns.quad4.io", port: 4242, isBootstrap: true),
TcpCommunityServer(name: "FireZen Hub", host: "reticulum.firezen.xyz", port: 4242, isBootstrap: true),
TcpCommunityServer(name: "Quad4 TCP Node 1", host: "rns.quad4.io", port: 4242, isBootstrap: true),
TcpCommunityServer(name: "FireZen", host: "firezen.com", port: 4242, isBootstrap: true),

// Community servers
TcpCommunityServer(name: "RNS Amsterdam", host: "amsterdam.connect.reticulum.network", port: 4965, isBootstrap: false),
TcpCommunityServer(name: "RNS BetweenTheBorders", host: "betweentheborders.com", port: 4242, isBootstrap: false),
TcpCommunityServer(name: "RNS Frankfurt", host: "frankfurt.connect.reticulum.network", port: 5377, isBootstrap: false),
TcpCommunityServer(name: "i2p Reticulum", host: "uxg5a4t3pnif7zoo43fkdrhgamlbfcovgsrzjakqab3pxjfqwdcq.b32.i2p", port: 5001, isBootstrap: false),
TcpCommunityServer(name: "Reticulum Ireland", host: "reticulum.liamcottle.net", port: 4242, isBootstrap: false),
TcpCommunityServer(name: "TheHub", host: "thehub.duckdns.org", port: 4242, isBootstrap: false),
TcpCommunityServer(name: "Kosciuszko", host: "kosciuszko.au.int.rns.directory", port: 9696, isBootstrap: false),
TcpCommunityServer(name: "Reticulum Ireland v2", host: "reticulum.liamcottle.net", port: 4343, isBootstrap: false),
TcpCommunityServer(name: "RNS Roaming", host: "roaming.int.rns.directory", port: 9697, isBootstrap: false),
TcpCommunityServer(name: "g00n.cloud Hub", host: "dfw.us.g00n.cloud", port: 6969, isBootstrap: false),
TcpCommunityServer(name: "noDNS1", host: "202.61.243.41", port: 4965, isBootstrap: false),
TcpCommunityServer(name: "noDNS2", host: "193.26.158.230", port: 4965, isBootstrap: false),
Comment on lines +41 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Placeholder names shipped as user-visible server labels

"noDNS1" and "noDNS2" look like internal identifiers rather than display-ready names. Both appear directly in the TCPServerSelectionStep server picker as the Text(server.name) label, so users will see these literal strings when choosing a community server. If these nodes have recognisable operator names (e.g. from the Android list or directory.rns.recipes), replace them before this lands; if they are intentionally anonymous nodes, a more descriptive label (e.g. "Anonymous Node 1" with the IP shown as the address) would communicate the distinction more clearly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/ColumbaApp/Models/TcpCommunityServer.swift
Line: 41-42

Comment:
**Placeholder names shipped as user-visible server labels**

`"noDNS1"` and `"noDNS2"` look like internal identifiers rather than display-ready names. Both appear directly in the `TCPServerSelectionStep` server picker as the `Text(server.name)` label, so users will see these literal strings when choosing a community server. If these nodes have recognisable operator names (e.g. from the Android list or directory.rns.recipes), replace them before this lands; if they are intentionally anonymous nodes, a more descriptive label (e.g. `"Anonymous Node 1"` with the IP shown as the address) would communicate the distinction more clearly.

How can I resolve this? If you propose a fix, please make it concise.

TcpCommunityServer(name: "NomadNode SEAsia TCP", host: "rns.jaykayenn.net", port: 4242, isBootstrap: false),
TcpCommunityServer(name: "0rbit-Net", host: "93.95.227.8", port: 49952, isBootstrap: false),
TcpCommunityServer(name: "Quad4 TCP Node 2", host: "rns2.quad4.io", port: 4242, isBootstrap: false),
TcpCommunityServer(name: "SparkN0de", host: "aspark.uber.space", port: 44860, isBootstrap: false),
]

/// Default server for first-time connections.
Expand Down
53 changes: 52 additions & 1 deletion Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ private let logger = Logger(subsystem: "network.columba.Columba", category: "Int
/// with InterfaceRepository for persistence.
@available(iOS 17.0, macOS 14.0, *)
@Observable
public final class InterfaceManagementViewModel {
public final class InterfaceManagementViewModel: TCPClientWizardSaveSink {

// MARK: - Dependencies

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

/// Whether the TCP client wizard is shown (community server picker → review/configure)
public var showTCPWizard: Bool = false

/// Interface being edited (nil for new interface)
public var editingInterface: InterfaceEntity?

Expand Down Expand Up @@ -215,6 +218,8 @@ public final class InterfaceManagementViewModel {

if type == .rnode {
showRNodeWizard = true
} else if type == .tcpClient {
showTCPWizard = true
} else {
showConfigSheet = true
}
Expand All @@ -226,6 +231,8 @@ public final class InterfaceManagementViewModel {
populateConfigForm(from: interface)
if interface.type == .rnode {
showRNodeWizard = true
} else if interface.type == .tcpClient {
showTCPWizard = true
} else {
showConfigSheet = true
}
Expand All @@ -235,6 +242,7 @@ public final class InterfaceManagementViewModel {
public func dismissConfigSheet() {
showConfigSheet = false
showRNodeWizard = false
showTCPWizard = false
editingInterface = nil
resetConfigForm()
}
Expand Down Expand Up @@ -280,6 +288,49 @@ public final class InterfaceManagementViewModel {
}
}

/// Save a TCP client interface from the wizard flow.
///
/// Bypasses the form-field validation path (the wizard does its own validation
/// in `canProceed`) and writes directly through the repository, then triggers
/// the standard apply-changes pipeline.
public func saveTCPInterface(
editing: InterfaceEntity?,
name: String,
enabled: Bool,
mode: InterfaceMode,
config: TCPClientConfig
) {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
let interfaceConfig: InterfaceTypeConfig = .tcpClient(config)

if let existing = editing {
var updated = existing
updated.name = trimmedName
updated.enabled = enabled
updated.mode = mode
updated.config = interfaceConfig
repository.updateInterface(updated)
showSuccess("Interface updated")
} else {
let newInterface = InterfaceEntity(
name: trimmedName,
type: .tcpClient,
enabled: enabled,
mode: mode,
config: interfaceConfig
)
repository.addInterface(newInterface)
showSuccess("Interface added")
}

hasPendingChanges = true
dismissConfigSheet()

Task { @MainActor in
await applyChanges()
}
}

// MARK: - Apply Changes

/// Apply pending interface changes to the running network.
Expand Down
195 changes: 195 additions & 0 deletions Sources/ColumbaApp/ViewModels/TCPClientWizardViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//
// TCPClientWizardViewModel.swift
// ColumbaApp
//
// State management for the 2-step TCP client interface configuration wizard.
// Mirrors the Android Columba TcpClientWizardViewModel.
//

import Foundation
import SwiftUI
import ReticulumSwift

// MARK: - Wizard Step

/// Steps in the TCP client configuration wizard.
@available(iOS 17.0, macOS 14.0, *)
enum TCPClientWizardStep: Int, CaseIterable, Identifiable {
case serverSelection = 0
case reviewConfigure = 1

var id: Int { rawValue }

var title: String {
switch self {
case .serverSelection: return "Select Server"
case .reviewConfigure: return "Review & Configure"
}
}
}

// MARK: - Parent Save Sink

/// Minimal protocol the wizard uses to forward a built TCP config to the
/// parent `InterfaceManagementViewModel`. Lets tests stub the parent without
/// pulling in repository / AppServices wiring.
@available(iOS 17.0, macOS 14.0, *)
protocol TCPClientWizardSaveSink: AnyObject {
func saveTCPInterface(
editing: InterfaceEntity?,
name: String,
enabled: Bool,
mode: InterfaceMode,
config: TCPClientConfig
)
}

// MARK: - ViewModel

/// ViewModel for the TCP client configuration wizard.
///
/// Manages step navigation, server selection vs custom mode, edit-mode
/// pre-population, and forwards the built `TCPClientConfig` through a
/// `TCPClientWizardSaveSink` so the existing add/update path on
/// `InterfaceManagementViewModel` stays the single source of persistence.
@available(iOS 17.0, macOS 14.0, *)
@Observable
@MainActor
final class TCPClientWizardViewModel {

// MARK: - Navigation

var currentStep: TCPClientWizardStep = .serverSelection

// MARK: - Step 1: Server Selection

var selectedServer: TcpCommunityServer?
var isCustomMode: Bool = false

// MARK: - Step 2: Review & Configure

var interfaceName: String = ""
var targetHost: String = ""
var targetPort: String = "4242"
var networkName: String = ""
var passphrase: String = ""
var showPassphrase: Bool = false
var mode: InterfaceMode = .full
var enabled: Bool = true
var showAdvanced: Bool = false

// MARK: - Edit Context

/// The interface being edited (nil for create flow).
private(set) var editingInterface: InterfaceEntity?

/// Whether this wizard run is editing an existing interface.
var isEditing: Bool { editingInterface != nil }

// MARK: - Step 1 Actions

/// Pre-fill name/host/port from a community server and clear custom mode.
func selectServer(_ server: TcpCommunityServer) {
selectedServer = server
isCustomMode = false
interfaceName = server.name
targetHost = server.host
targetPort = String(server.port)
}

/// Switch to custom-server mode: clear the selection and blank
/// the name/host/port fields so the user types fresh values in step 2.
func enableCustomMode() {
selectedServer = nil
isCustomMode = true
interfaceName = ""
targetHost = ""
targetPort = ""
}

// MARK: - Edit Pre-population

/// Populate fields from an existing TCP interface.
///
/// If `(host, port)` matches a known `TcpCommunityServer`, that server
/// is selected and the wizard opens at step 1. Otherwise the wizard opens
/// at step 1 in custom mode so the user can confirm or change the entry.
func loadExisting(_ entity: InterfaceEntity) {
guard case .tcpClient(let config) = entity.config else { return }
editingInterface = entity
interfaceName = entity.name
targetHost = config.targetHost
targetPort = String(config.targetPort)
networkName = config.networkName ?? ""
passphrase = config.passphrase ?? ""
mode = entity.mode
enabled = entity.enabled

let match = TcpCommunityServer.servers.first { server in
server.host == config.targetHost && server.port == config.targetPort
}
if let match = match {
selectedServer = match
isCustomMode = false
} else {
selectedServer = nil
isCustomMode = true
}
currentStep = .serverSelection
}

// MARK: - Validation

/// Whether the wizard can advance / save from the given step.
func canProceed(from step: TCPClientWizardStep) -> Bool {
switch step {
case .serverSelection:
return selectedServer != nil || isCustomMode
case .reviewConfigure:
let host = targetHost.trimmingCharacters(in: .whitespaces)
guard !host.isEmpty else { return false }
guard let port = UInt16(targetPort.trimmingCharacters(in: .whitespaces)),
port > 0 else {
return false
}
let trimmedName = interfaceName.trimmingCharacters(in: .whitespaces)
return !trimmedName.isEmpty
}
}

// MARK: - Step Navigation

func goToReview() {
currentStep = .reviewConfigure
}

func goToServerSelection() {
currentStep = .serverSelection
}

// MARK: - Save

/// Build the `TCPClientConfig` and forward it to the parent through the
/// save sink. Persistence + apply-changes stay on the parent.
func save(into sink: TCPClientWizardSaveSink) {
let trimmedHost = targetHost.trimmingCharacters(in: .whitespaces)
let port = UInt16(targetPort.trimmingCharacters(in: .whitespaces)) ?? 4242
let trimmedNetwork = networkName.trimmingCharacters(in: .whitespaces)
let trimmedPassphrase = passphrase.trimmingCharacters(in: .whitespaces)

let config = TCPClientConfig(
targetHost: trimmedHost,
targetPort: port,
networkName: trimmedNetwork.isEmpty ? nil : trimmedNetwork,
passphrase: trimmedPassphrase.isEmpty ? nil : trimmedPassphrase
)

sink.saveTCPInterface(
editing: editingInterface,
name: interfaceName.trimmingCharacters(in: .whitespaces),
enabled: enabled,
mode: mode,
config: config
)
}
}
Loading
Loading