mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
* Initial implementation of transports * Initial LogRadio implementation * Fixes for Settings view (caused by debug commenting) * Refinement of the object and actor model * Connect view text and tab updates * Fix mac catalyst and tests * Warning and logging clean-up * In progress commit * Serial Transport and Reconnect draft work * Serial transport and reconnection draft work * Quick fix for BLE - still more work to do * interim commit * More in progress changes * Minor improvements * Pretty good initial implementation * Bump version beyond the app store * Fix for disconnection swipeAction * Tweaks to TCPConnection implementation * Retry for NONCE_ONLY_DB * Revert json string change * Simplified some of the API + "Anti-discovery" * Tweaks for devices leaving the discovery process * Bump version * iOS26 Tweaks * Tweaks and bug fixes * Add link with slash sf symbol * update symbol image on connect view * BLE disconnect handling * Log privacy attributes * Onboarding and minor fixes. * change database to nodes, add emoji to tcp logs * Error handling improvements * More logging emojis * Suppressed unnecessary errors on disconnect * Heartbeat emoji * Add bluetooth symbol * add privacy attributes to [TCP] logs, add custom bluetooth logo * Improve routing logs * Emoji for connect logs * Heartbeat emoji * Add CBCentralManagerScanOptionAllowDuplicatesKey options to central for bluetooth * fix nav errors by switching from observableobject to state * Update connection indicator icon * fix for BLE disconnects * Connection process fixes * More fixes/tweaks to connection process * Strict concurrency * Fix some warnings, remove wifi warning * delete stale keys * interim commit * Update privacy for log, fix wrong space * fix a couple of linting items * Switch to targeted * interim commit * BLE Signal strenth on connect view * Remove BLE RSSI from long press menu * Modem lights * minor spacing tweak * Additional BLE logging and a scanning fix. * Discovery and BLE RSSI improvements * Background suspension * Update isConnected to enable UI during db load * update protobufs * Replace config if statements with switches, Fix unknown module config logging, make dark mode modem circle stroke color white so they are visible * Additional logging cleanup * hast * Set unmessagable to true if the longname has the unmessagable emoji * Connect error handling improvements * Admin popup list icon and activity lights updates * Revert use of .toolbar back to .navigationBarItems * More public logging * Better BLE error handling * Node DB progress meter * minor tweak to activity light interaction timing * Fix comment linting, remove stale keys * Remove stale keys * Easy linting fixes * Two more simple linting fixes * clean up meshtasticapp * More public logging * Replay config * Logging * Fix for unselected node on Settings * Tweak to progress meter based on device idiom * Update protos * Session replay redaction of messages * Serial fix for old devices, and a let statement * Mask text too * Fix typo * BLE poweredOff is now an auto-reconnectable error * Update logging * Fix for peerRemovedPairingInformation * Logging for BLE peripheral:didUpdateValueFor errors. * Fix for inconsistent swipe disconnect behavior * periperal:didUpdateValueFor error handling * Fix for BLEConnection continuation guarding * BLEConnection actor deadlock on disconnect * Heartbeat nonce * Fix for swipe disconnect and task cancellation * Fix for swipe actions not honoring .disabled() * Tell BLETransport when BLEConnection is cancelled * Update navigation logging * Logging updates * Bump version to 2.7.0 * Organize into folders and heartbeat stuff * Minor improvements to manual TCP connection * Auto-connect toggle * Possible BLE bug, still waiting to see in logs * Concurrency tweaks * Concurrency improvements * requestDeviceMetadata fix. fixes remote admin * Minor typo fixes * "All" button for log filters: category and level * More robust continuation handling for BLE * @FetchRequest based ChannelMessageList * Update info.plist and device hardware file * Move auto connect toggle to app settings and debug mode, tint properly with the accent color * Add label to auto connect toggle * Update log for node info received from ourselves over the mesh * Remove unused scrollViewProxy * Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update target for connect view * Properly Set datadog environment * Comment out ble manager * Adjust cyclomatic complexity thresholds in .swiftlint.yml * Linting fixes, delete ble manager * Make session replay debug only --------- Co-authored-by: jake-b <jake-b@users.noreply.github.com> Co-authored-by: jake <jake@jakes-Mac-mini.local> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
517 lines
18 KiB
Swift
517 lines
18 KiB
Swift
//
|
|
// Connect.swift
|
|
// Meshtastic Apple
|
|
//
|
|
// Copyright(c) Garth Vander Houwen 8/18/21.
|
|
//
|
|
|
|
import SwiftUI
|
|
import MapKit
|
|
import CoreData
|
|
import CoreLocation
|
|
import CoreBluetooth
|
|
import OSLog
|
|
import TipKit
|
|
#if canImport(ActivityKit)
|
|
import ActivityKit
|
|
#endif
|
|
|
|
struct Connect: View {
|
|
|
|
@Environment(\.managedObjectContext) var context
|
|
@EnvironmentObject var accessoryManager: AccessoryManager
|
|
@State var router: Router
|
|
@State var node: NodeInfoEntity?
|
|
@State var isUnsetRegion = false
|
|
@State var invalidFirmwareVersion = false
|
|
@State var liveActivityStarted = false
|
|
@State var presentingSwitchPreferredPeripheral = false
|
|
@State var selectedPeripherialId = ""
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack {
|
|
List {
|
|
Section {
|
|
if let connectedDevice = accessoryManager.activeConnection?.device,
|
|
accessoryManager.isConnected || accessoryManager.isConnecting {
|
|
TipView(ConnectionTip(), arrowEdge: .bottom)
|
|
.tipViewStyle(PersistentTip())
|
|
VStack(alignment: .leading) {
|
|
HStack {
|
|
VStack(alignment: .center) {
|
|
CircleText(text: node?.user?.shortName?.addingVariationSelectors ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90)
|
|
.padding(.trailing, 5)
|
|
if node?.latestDeviceMetrics != nil {
|
|
BatteryCompact(batteryLevel: node?.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor)
|
|
.padding(.trailing, 5)
|
|
}
|
|
}
|
|
.padding(.trailing)
|
|
VStack(alignment: .leading) {
|
|
if node != nil {
|
|
Text(connectedDevice.longName?.addingVariationSelectors ?? "Unknown".localized).font(.title2)
|
|
}
|
|
Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)")
|
|
.font(.callout).foregroundColor(Color.gray)
|
|
HStack(alignment: .firstTextBaseline) {
|
|
TransportIcon(transportType: connectedDevice.transportType)
|
|
if connectedDevice.transportType == .ble {
|
|
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(0)
|
|
if node != nil {
|
|
Text("Firmware Version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "Unknown".localized)")
|
|
.font(.callout).foregroundColor(Color.gray)
|
|
}
|
|
switch accessoryManager.state {
|
|
case .subscribed:
|
|
Text("Subscribed").font(.callout)
|
|
.foregroundColor(.green)
|
|
case .retrievingDatabase(let nodeCount):
|
|
HStack {
|
|
Image(systemName: "square.stack.3d.down.forward")
|
|
.symbolRenderingMode(.multicolor)
|
|
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
|
.foregroundColor(.teal)
|
|
if let expectedNodeDBSize = accessoryManager.expectedNodeDBSize {
|
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
|
VStack(alignment: .leading, spacing: 2.0) {
|
|
Text("Retrieving nodes").font(.callout)
|
|
.foregroundColor(.teal)
|
|
ProgressView(value: Double(nodeCount), total: Double(expectedNodeDBSize))
|
|
}
|
|
} else {
|
|
// iPad/Mac with more space, show progress bar AFTER the label
|
|
HStack {
|
|
Text("Retrieving nodes").font(.callout)
|
|
.foregroundColor(.teal)
|
|
ProgressView(value: Double(nodeCount), total: Double(expectedNodeDBSize))
|
|
}
|
|
}
|
|
|
|
} else {
|
|
Text("Retrieving nodes \(nodeCount)").font(.callout)
|
|
.foregroundColor(.teal)
|
|
}
|
|
}
|
|
case .communicating:
|
|
HStack {
|
|
Image(systemName: "square.stack.3d.down.forward")
|
|
.symbolRenderingMode(.multicolor)
|
|
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
|
.foregroundColor(.orange)
|
|
Text("Communicating").font(.callout)
|
|
.foregroundColor(.orange)
|
|
}
|
|
case .retrying(let attempt):
|
|
HStack {
|
|
Image(systemName: "square.stack.3d.down.forward")
|
|
.symbolRenderingMode(.multicolor)
|
|
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
|
.foregroundColor(.orange)
|
|
Text("Retrying (attempt \(attempt))").font(.callout)
|
|
.foregroundColor(.orange)
|
|
}
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(Color.gray)
|
|
.padding([.top])
|
|
.swipeActions {
|
|
if accessoryManager.allowDisconnect {
|
|
Button(role: .destructive) {
|
|
Task {
|
|
try await accessoryManager.disconnect()
|
|
}
|
|
} label: {
|
|
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
|
}.disabled(!accessoryManager.allowDisconnect)
|
|
}
|
|
}
|
|
.contextMenu {
|
|
|
|
if node != nil {
|
|
Label("\(String(node!.num))", systemImage: "number")
|
|
#if !targetEnvironment(macCatalyst)
|
|
if accessoryManager.state == .subscribed {
|
|
Button {
|
|
if !liveActivityStarted {
|
|
#if canImport(ActivityKit)
|
|
Logger.services.info("Start live activity.")
|
|
startNodeActivity()
|
|
#endif
|
|
} else {
|
|
#if canImport(ActivityKit)
|
|
Logger.services.info("Stop live activity.")
|
|
endActivity()
|
|
#endif
|
|
}
|
|
} label: {
|
|
Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play")
|
|
}
|
|
}
|
|
#endif
|
|
if accessoryManager.allowDisconnect {
|
|
Button(role: .destructive) {
|
|
if accessoryManager.allowDisconnect {
|
|
Task {
|
|
try await accessoryManager.disconnect()
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
|
}
|
|
Button(role: .destructive) {
|
|
Task {
|
|
do {
|
|
try await accessoryManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!)
|
|
} catch {
|
|
Logger.mesh.error("Shutdown Failed: \(error)")
|
|
}
|
|
}
|
|
|
|
} label: {
|
|
Label("Power Off", systemImage: "power")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if isUnsetRegion {
|
|
HStack {
|
|
NavigationLink {
|
|
LoRaConfig(node: node)
|
|
} label: {
|
|
Label("Set LoRa Region", systemImage: "globe.americas.fill")
|
|
.foregroundColor(.red)
|
|
.font(.title)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if accessoryManager.isConnecting {
|
|
HStack {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.resizable()
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundColor(.orange)
|
|
.frame(width: 60, height: 60)
|
|
.padding(.trailing)
|
|
switch accessoryManager.state {
|
|
case .connecting, .communicating:
|
|
Text("Connecting . .")
|
|
.font(.title2)
|
|
.foregroundColor(.orange)
|
|
case .retrievingDatabase:
|
|
Text("Retreiving nodes . .")
|
|
.font(.callout)
|
|
.foregroundColor(.orange)
|
|
case .retrying(let attempt):
|
|
Text("Connection Attempt \(attempt) of 10")
|
|
.font(.callout)
|
|
.foregroundColor(.orange)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.padding()
|
|
.swipeActions {
|
|
if accessoryManager.allowDisconnect {
|
|
Button(role: .destructive) {
|
|
Task {
|
|
try await accessoryManager.disconnect()
|
|
}
|
|
} label: {
|
|
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
|
}.disabled(!accessoryManager.allowDisconnect)
|
|
}
|
|
}
|
|
|
|
} else {
|
|
|
|
if let lastError = accessoryManager.lastConnectionError as? Error {
|
|
Text(lastError.localizedDescription).font(.callout).foregroundColor(.red)
|
|
}
|
|
HStack {
|
|
Image("custom.link.slash")
|
|
.resizable()
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundColor(.red)
|
|
.frame(width: 60, height: 60)
|
|
.padding(.trailing)
|
|
Text("No device connected").font(.title3)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.textCase(nil)
|
|
|
|
if !(accessoryManager.isConnected || accessoryManager .isConnecting) {
|
|
Section(header: HStack {
|
|
Text("Available Radios").font(.title)
|
|
Spacer()
|
|
ManualConnectionMenu()
|
|
}) {
|
|
ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in
|
|
HStack {
|
|
if UserDefaults.preferredPeripheralId == device.id.uuidString {
|
|
Image(systemName: "star.fill")
|
|
.imageScale(.large).foregroundColor(.yellow)
|
|
.padding(.trailing)
|
|
} else {
|
|
Image(systemName: "circle.fill")
|
|
.imageScale(.large).foregroundColor(.gray)
|
|
.padding(.trailing)
|
|
}
|
|
VStack(alignment: .leading) {
|
|
Button(action: {
|
|
if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId {
|
|
if accessoryManager.allowDisconnect {
|
|
Task { try await accessoryManager.disconnect() }
|
|
}
|
|
presentingSwitchPreferredPeripheral = true
|
|
selectedPeripherialId = device.id.uuidString
|
|
} else {
|
|
Task {
|
|
try? await accessoryManager.connect(to: device)
|
|
}
|
|
}
|
|
}) {
|
|
Text(device.name).font(.callout)
|
|
}
|
|
// Show transport type
|
|
TransportIcon(transportType: device.transportType)
|
|
}
|
|
Spacer()
|
|
VStack {
|
|
device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) }
|
|
}
|
|
}.padding([.bottom, .top])
|
|
}
|
|
}
|
|
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
|
|
Button("Connect to new radio?", role: .destructive) {
|
|
UserDefaults.preferredPeripheralId = selectedPeripherialId
|
|
UserDefaults.preferredPeripheralNum = 0
|
|
if accessoryManager.allowDisconnect {
|
|
Task { try await accessoryManager.disconnect() }
|
|
}
|
|
clearCoreDataDatabase(context: context, includeRoutes: false)
|
|
if let radio = accessoryManager.devices.first(where: { $0.id.uuidString == selectedPeripherialId }) {
|
|
Task {
|
|
try await accessoryManager.connect(to: radio)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .center) {
|
|
Spacer()
|
|
#if targetEnvironment(macCatalyst)
|
|
// TODO: should this be allowDisconnect?
|
|
if accessoryManager.allowDisconnect {
|
|
Button(role: .destructive, action: {
|
|
if accessoryManager.allowDisconnect {
|
|
Task {
|
|
try await accessoryManager.disconnect()
|
|
}
|
|
}
|
|
}) {
|
|
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.buttonBorderShape(.capsule)
|
|
.controlSize(.large)
|
|
.padding()
|
|
}
|
|
#endif
|
|
Spacer()
|
|
}
|
|
.padding(.bottom, 10)
|
|
}
|
|
.navigationTitle("Connect")
|
|
.navigationBarItems(
|
|
leading: MeshtasticLogo(),
|
|
trailing: ZStack {
|
|
ConnectedDevice(
|
|
deviceConnected: accessoryManager.isConnected,
|
|
name: accessoryManager.activeConnection?.device.shortName ?? "?",
|
|
mqttProxyConnected: accessoryManager.mqttProxyConnected,
|
|
mqttTopic: accessoryManager.mqttManager.topic
|
|
|
|
)
|
|
}
|
|
)
|
|
|
|
}
|
|
// TODO: REMOVING VERSION STUFF?
|
|
// .sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) {
|
|
// InvalidVersion(minimumVersion: accessoryManager.minimumVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?")
|
|
// .presentationDetents([.large])
|
|
// .presentationDragIndicator(.automatic)
|
|
// }
|
|
// .onChange(of: accessoryManager) {
|
|
// invalidFirmwareVersion = self.bleManager.invalidVersion
|
|
// }
|
|
.onChange(of: self.accessoryManager.state) { _, state in
|
|
|
|
if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed {
|
|
|
|
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
|
|
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", deviceNum)
|
|
|
|
do {
|
|
node = try context.fetch(fetchNodeInfoRequest).first
|
|
if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue {
|
|
isUnsetRegion = true
|
|
} else {
|
|
isUnsetRegion = false
|
|
}
|
|
} catch {
|
|
Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#if !targetEnvironment(macCatalyst)
|
|
#if canImport(ActivityKit)
|
|
func startNodeActivity() {
|
|
liveActivityStarted = true
|
|
// 15 Minutes Local Stats Interval
|
|
let timerSeconds = 900
|
|
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
|
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
|
|
|
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown")
|
|
|
|
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
|
let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
|
channelUtilization: mostRecent?.channelUtilization ?? 0.0,
|
|
airtime: mostRecent?.airUtilTx ?? 0.0,
|
|
sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0),
|
|
receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0),
|
|
badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0),
|
|
dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0),
|
|
packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0),
|
|
packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0),
|
|
nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0),
|
|
totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0),
|
|
timerRange: Date.now...future)
|
|
|
|
let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!)
|
|
|
|
do {
|
|
let myActivity = try Activity<MeshActivityAttributes>.request(attributes: activityAttributes, content: activityContent,
|
|
pushType: nil)
|
|
Logger.services.info("Requested MyActivity live activity. ID: \(myActivity.id)")
|
|
} catch {
|
|
Logger.services.error("Error requesting live activity: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
func endActivity() {
|
|
liveActivityStarted = false
|
|
Task {
|
|
for activity in Activity<MeshActivityAttributes>.activities where activity.attributes.nodeNum == node?.num ?? 0 {
|
|
await activity.end(nil, dismissalPolicy: .immediate)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
func didDismissSheet() {
|
|
// bleManager.disconnectPeripheral(reconnect: false)
|
|
Task {
|
|
try await accessoryManager.disconnect()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TransportIcon: View {
|
|
var transportType: TransportType
|
|
@EnvironmentObject var accessoryManager: AccessoryManager
|
|
|
|
var body: some View {
|
|
let transport = accessoryManager.transportForType(transportType)
|
|
return HStack(spacing: 3.0) {
|
|
if let icon = transport?.type.icon {
|
|
icon
|
|
.font(.title2)
|
|
.foregroundColor(transport?.type == .ble ? Color.accentColor : Color.primary)
|
|
} else {
|
|
Image(systemName: "questionmark")
|
|
.font(.title2)
|
|
}
|
|
Text(transport?.type.rawValue ?? "Unknown".localized)
|
|
.font(.title3)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ManualConnectionMenu: View {
|
|
private struct IterableTransport: Identifiable {
|
|
let id: UUID
|
|
let icon: Image
|
|
let title: String
|
|
let transport: any Transport
|
|
}
|
|
|
|
private var transports: [IterableTransport]
|
|
|
|
init() {
|
|
self.transports = AccessoryManager.shared.transports.filter { $0.supportsManualConnection}.map { transport in
|
|
IterableTransport(id: UUID(), icon: transport.type.icon, title: transport.type.rawValue, transport: transport)
|
|
}
|
|
}
|
|
|
|
@State private var selectedTransport: IterableTransport?
|
|
@State private var showAlert: Bool = false
|
|
@State private var connectionString = ""
|
|
|
|
var body: some View {
|
|
Menu {
|
|
ForEach(transports) { transport in
|
|
Button {
|
|
self.selectedTransport = transport
|
|
self.showAlert = true
|
|
} label: {
|
|
Label(title: { Text(transport.title)}, icon: { transport.icon })
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Manual", systemImage: "plus")
|
|
}.alert("Manual connection string", isPresented: $showAlert, presenting: selectedTransport) { selectedTransport in
|
|
// This continues to be quick and dirty. A better system is needed.
|
|
TextField("Enter hostname[:port]", text: $connectionString)
|
|
.keyboardType(.URL)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.onChange(of: connectionString) { _, newValue in
|
|
// Filter to only allow valid characters for hostname/IP:port
|
|
let allowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:")
|
|
let filtered = String(newValue.unicodeScalars.filter { allowedCharacters.contains($0) })
|
|
if filtered != newValue {
|
|
connectionString = filtered
|
|
}
|
|
}
|
|
|
|
Button("OK", action: {
|
|
if !connectionString.isEmpty {
|
|
Task {
|
|
try await selectedTransport.transport.manuallyConnect(withConnectionString: connectionString)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|