mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-02 02:55:57 +00:00
405 lines
18 KiB
Swift
405 lines
18 KiB
Swift
//
|
|
// AdvancedNetworkSettings.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by Evgeny on 02/08/2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
// Spec: spec/architecture.md
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
|
|
private let secondsLabel = NSLocalizedString("sec", comment: "network option")
|
|
|
|
enum NetworkSettingsAlert: Identifiable {
|
|
case update
|
|
case error(err: String)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .update: return "update"
|
|
case let .error(err): return "error \(err)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AdvancedNetworkSettings: View {
|
|
@Environment(\.dismiss) var dismiss: DismissAction
|
|
@EnvironmentObject var theme: AppTheme
|
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
|
@State private var netCfg = NetCfg.defaults
|
|
@State private var currentNetCfg = NetCfg.defaults
|
|
@State private var cfgLoaded = false
|
|
@State private var enableKeepAlive = true
|
|
@State private var keepAliveOpts = KeepAliveOpts.defaults
|
|
@State private var showSettingsAlert: NetworkSettingsAlert?
|
|
@State private var onionHosts: OnionHosts = .no
|
|
@State private var showSaveDialog = false
|
|
@State private var netProxy = networkProxyDefault.get()
|
|
@State private var currentNetProxy = networkProxyDefault.get()
|
|
@State private var useNetProxy = false
|
|
@State private var netProxyAuth = false
|
|
|
|
var body: some View {
|
|
VStack {
|
|
List {
|
|
Section {
|
|
NavigationLink {
|
|
List {
|
|
Section {
|
|
SelectionListView(list: SMPProxyMode.values, selection: $netCfg.smpProxyMode) { mode in
|
|
netCfg.smpProxyMode = mode
|
|
}
|
|
} footer: {
|
|
Text(proxyModeInfo(netCfg.smpProxyMode))
|
|
.font(.callout)
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
.navigationTitle("Private routing")
|
|
.modifier(ThemedBackground(grouped: true))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
} label: {
|
|
HStack {
|
|
Text("Private routing")
|
|
Spacer()
|
|
Text(netCfg.smpProxyMode.label)
|
|
}
|
|
}
|
|
|
|
NavigationLink {
|
|
List {
|
|
Section {
|
|
SelectionListView(list: SMPProxyFallback.values, selection: $netCfg.smpProxyFallback) { mode in
|
|
netCfg.smpProxyFallback = mode
|
|
}
|
|
.disabled(netCfg.smpProxyMode == .never)
|
|
} footer: {
|
|
Text(proxyFallbackInfo(netCfg.smpProxyFallback))
|
|
.font(.callout)
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
.navigationTitle("Allow downgrade")
|
|
.modifier(ThemedBackground(grouped: true))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
} label: {
|
|
HStack {
|
|
Text("Allow downgrade")
|
|
Spacer()
|
|
Text(netCfg.smpProxyFallback.label)
|
|
}
|
|
}
|
|
|
|
Toggle("Show message status", isOn: $showSentViaProxy)
|
|
} header: {
|
|
Text("Private message routing")
|
|
.foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
VStack(alignment: .leading) {
|
|
Text("To protect your IP address, private routing uses your SMP servers to deliver messages.")
|
|
if showSentViaProxy {
|
|
Text("Show → on messages sent via private routing.")
|
|
}
|
|
}
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
|
|
Section {
|
|
Toggle("Use SOCKS proxy", isOn: $useNetProxy)
|
|
Group {
|
|
TextField("IP address", text: $netProxy.host)
|
|
TextField(
|
|
"Port",
|
|
text: Binding(
|
|
get: { netProxy.port > 0 ? "\(netProxy.port)" : "" },
|
|
set: { s in
|
|
netProxy.port = if let port = Int(s), port > 0 {
|
|
port
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
)
|
|
)
|
|
Toggle("Proxy requires password", isOn: $netProxyAuth)
|
|
if netProxyAuth {
|
|
TextField("Username", text: $netProxy.username)
|
|
PassphraseField(
|
|
key: $netProxy.password,
|
|
placeholder: "Password",
|
|
valid: NetworkProxy.validCredential(netProxy.password)
|
|
)
|
|
}
|
|
}
|
|
.if(!useNetProxy) { $0.foregroundColor(theme.colors.secondary) }
|
|
.disabled(!useNetProxy)
|
|
} header: {
|
|
HStack {
|
|
Text("SOCKS proxy").foregroundColor(theme.colors.secondary)
|
|
if useNetProxy && !netProxy.valid {
|
|
Spacer()
|
|
Image(systemName: "exclamationmark.circle.fill").foregroundColor(.red)
|
|
}
|
|
}
|
|
} footer: {
|
|
if netProxyAuth {
|
|
Text("Your credentials may be sent unencrypted.")
|
|
.foregroundColor(theme.colors.secondary)
|
|
} else {
|
|
Text("Do not use credentials with proxy.")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
.onChange(of: useNetProxy) { useNetProxy in
|
|
netCfg.socksProxy = useNetProxy && currentNetProxy.valid
|
|
? currentNetProxy.toProxyString()
|
|
: nil
|
|
netProxy = currentNetProxy
|
|
netProxyAuth = netProxy.username != "" || netProxy.password != ""
|
|
}
|
|
.onChange(of: netProxyAuth) { netProxyAuth in
|
|
if netProxyAuth {
|
|
netProxy.auth = currentNetProxy.auth
|
|
netProxy.username = currentNetProxy.username
|
|
netProxy.password = currentNetProxy.password
|
|
} else {
|
|
netProxy.auth = .username
|
|
netProxy.username = ""
|
|
netProxy.password = ""
|
|
}
|
|
}
|
|
.onChange(of: netProxy) { netProxy in
|
|
netCfg.socksProxy = useNetProxy && netProxy.valid
|
|
? netProxy.toProxyString()
|
|
: nil
|
|
}
|
|
|
|
Section {
|
|
Picker("Use .onion hosts", selection: $onionHosts) {
|
|
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
|
}
|
|
.frame(height: 36)
|
|
} footer: {
|
|
Text(onionHostsInfo(onionHosts))
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
.onChange(of: onionHosts) { hosts in
|
|
if hosts != OnionHosts(netCfg: currentNetCfg) {
|
|
let (hostMode, requiredHostMode) = hosts.hostMode
|
|
netCfg.hostMode = hostMode
|
|
netCfg.requiredHostMode = requiredHostMode
|
|
}
|
|
}
|
|
|
|
if developerTools {
|
|
Section {
|
|
WrappedPicker("Transport isolation", selection: $netCfg.sessionMode) {
|
|
let modes = TransportSessionMode.values.contains(netCfg.sessionMode)
|
|
? TransportSessionMode.values
|
|
: TransportSessionMode.values + [netCfg.sessionMode]
|
|
ForEach(modes, id: \.self) { Text($0.text) }
|
|
}
|
|
} footer: {
|
|
sessionModeInfo(netCfg.sessionMode)
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
WrappedPicker("Use web port", selection: $netCfg.smpWebPortServers) {
|
|
ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) }
|
|
}
|
|
} header: {
|
|
Text("TCP port for messaging")
|
|
} footer: {
|
|
netCfg.smpWebPortServers == .preset
|
|
? Text("Use TCP port 443 for preset servers only.")
|
|
: Text("Use TCP port \(netCfg.smpWebPortServers == .all ? "443" : "5223") when no port is specified.")
|
|
}
|
|
|
|
Section("TCP connection") {
|
|
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout.interactiveTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
|
timeoutSettingPicker("TCP connection bg timeout", selection: $netCfg.tcpConnectTimeout.backgroundTimeout, values: [30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
|
|
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout.interactiveTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000], label: secondsLabel)
|
|
timeoutSettingPicker("Protocol background timeout", selection: $netCfg.tcpTimeout.backgroundTimeout, values: [15_000000, 20_000000, 30_000000, 45_000000, 60_000000], label: secondsLabel)
|
|
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel)
|
|
// intSettingPicker("Receiving concurrency", selection: $netCfg.rcvConcurrency, values: [1, 2, 4, 8, 12, 16, 24], label: "")
|
|
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
|
|
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
|
|
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
|
|
|
|
if enableKeepAlive {
|
|
intSettingPicker("TCP_KEEPIDLE", selection: $keepAliveOpts.keepIdle, values: [15, 30, 60, 120, 180], label: secondsLabel)
|
|
intSettingPicker("TCP_KEEPINTVL", selection: $keepAliveOpts.keepIntvl, values: [5, 10, 15, 30, 60], label: secondsLabel)
|
|
intSettingPicker("TCP_KEEPCNT", selection: $keepAliveOpts.keepCnt, values: [1, 2, 4, 6, 8], label: "")
|
|
} else {
|
|
Group {
|
|
Text("TCP_KEEPIDLE")
|
|
Text("TCP_KEEPINTVL")
|
|
Text("TCP_KEEPCNT")
|
|
}
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button("Reset to defaults") {
|
|
updateNetCfgView(NetCfg.defaults, NetworkProxy.def)
|
|
}
|
|
.disabled(netCfg == NetCfg.defaults)
|
|
|
|
Button("Set timeouts for proxy/VPN") {
|
|
updateNetCfgView(netCfg.withProxyTimeouts, netProxy)
|
|
}
|
|
.disabled(netCfg.hasProxyTimeouts)
|
|
|
|
Button("Save and reconnect") {
|
|
showSettingsAlert = .update
|
|
}
|
|
.disabled(netCfg == currentNetCfg || (useNetProxy && !netProxy.valid))
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: keepAliveOpts) { opts in
|
|
netCfg.tcpKeepAlive = keepAliveOpts
|
|
}
|
|
.onChange(of: enableKeepAlive) { on in
|
|
netCfg.tcpKeepAlive = on ? (currentNetCfg.tcpKeepAlive ?? KeepAliveOpts.defaults) : nil
|
|
}
|
|
.onAppear {
|
|
if cfgLoaded { return }
|
|
cfgLoaded = true
|
|
currentNetCfg = getNetCfg()
|
|
currentNetProxy = networkProxyDefault.get()
|
|
updateNetCfgView(currentNetCfg, currentNetProxy)
|
|
}
|
|
.alert(item: $showSettingsAlert) { a in
|
|
switch a {
|
|
case .update:
|
|
return Alert(
|
|
title: Text("Update settings?"),
|
|
message: Text("Updating settings will re-connect the client to all servers."),
|
|
primaryButton: .default(Text("Ok")) {
|
|
_ = saveNetCfg()
|
|
},
|
|
secondaryButton: .cancel()
|
|
)
|
|
case let .error(err):
|
|
return Alert(
|
|
title: Text("Error updating settings"),
|
|
message: Text(err)
|
|
)
|
|
}
|
|
}
|
|
.modifier(BackButton(disabled: Binding.constant(false)) {
|
|
if netCfg == currentNetCfg {
|
|
dismiss()
|
|
cfgLoaded = false
|
|
} else if !useNetProxy || netProxy.valid {
|
|
showSaveDialog = true
|
|
}
|
|
})
|
|
.confirmationDialog("Update network settings?", isPresented: $showSaveDialog, titleVisibility: .visible) {
|
|
Button("Save and reconnect") {
|
|
if saveNetCfg() {
|
|
dismiss()
|
|
cfgLoaded = false
|
|
}
|
|
}
|
|
Button("Exit without saving") { dismiss() }
|
|
}
|
|
}
|
|
|
|
private func updateNetCfgView(_ cfg: NetCfg, _ proxy: NetworkProxy) {
|
|
netCfg = cfg
|
|
netProxy = proxy
|
|
onionHosts = OnionHosts(netCfg: netCfg)
|
|
enableKeepAlive = netCfg.enableKeepAlive
|
|
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
|
|
useNetProxy = netCfg.socksProxy != nil
|
|
netProxyAuth = switch netProxy.auth {
|
|
case .username: netProxy.username != "" || netProxy.password != ""
|
|
case .isolate: false
|
|
}
|
|
}
|
|
|
|
private func saveNetCfg() -> Bool {
|
|
do {
|
|
try setNetworkConfig(netCfg)
|
|
currentNetCfg = netCfg
|
|
setNetCfg(netCfg, networkProxy: useNetProxy ? netProxy : nil)
|
|
currentNetProxy = netProxy
|
|
networkProxyDefault.set(netProxy)
|
|
return true
|
|
} catch let error {
|
|
let err = responseError(error)
|
|
showSettingsAlert = .error(err: err)
|
|
logger.error("\(err)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func intSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View {
|
|
Picker(title, selection: selection) {
|
|
ForEach(values, id: \.self) { value in
|
|
Text("\(value) \(label)")
|
|
}
|
|
}
|
|
.frame(height: 36)
|
|
}
|
|
|
|
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View {
|
|
WrappedPicker(title, selection: selection) {
|
|
let v = selection.wrappedValue
|
|
let vs = values.contains(v) ? values : values + [v]
|
|
ForEach(vs, id: \.self) { value in
|
|
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
|
|
switch hosts {
|
|
case .no: return "Onion hosts will not be used."
|
|
case .prefer: return "Onion hosts will be used when available.\nRequires compatible VPN."
|
|
case .require: return "Onion hosts will be **required** for connection.\nRequires compatible VPN."
|
|
}
|
|
}
|
|
|
|
private func sessionModeInfo(_ mode: TransportSessionMode) -> Text {
|
|
let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.")
|
|
return switch mode {
|
|
case .user: userMode
|
|
case .session: userMode + textNewLine + Text("New SOCKS credentials will be used every time you start the app.")
|
|
case .server: userMode + textNewLine + Text("New SOCKS credentials will be used for each server.")
|
|
case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
|
|
}
|
|
}
|
|
|
|
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
|
|
switch mode {
|
|
case .always: return "Always use private routing."
|
|
case .unknown: return "Use private routing with unknown servers."
|
|
case .unprotected: return "Use private routing with unknown servers when IP address is not protected."
|
|
case .never: return "Do NOT use private routing."
|
|
}
|
|
}
|
|
|
|
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
|
|
switch proxyFallback {
|
|
case .allow: return "Send messages directly when your or destination server does not support private routing."
|
|
case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing."
|
|
case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AdvancedNetworkSettings_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
AdvancedNetworkSettings()
|
|
}
|
|
}
|