Skip to content

Commit e5a8213

Browse files
committed
Implement SwiftUI UI for UDP TCP Obfuscation port selector view
1 parent e914beb commit e5a8213

12 files changed

+275
-37
lines changed

ios/MullvadSettings/WireGuardObfuscationSettings.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ public struct WireGuardObfuscationSettings: Codable, Equatable {
124124
@available(*, deprecated, message: "Use `udpOverTcpPort` instead")
125125
private var port: WireGuardObfuscationPort = .automatic
126126

127-
public let state: WireGuardObfuscationState
128-
public let udpOverTcpPort: WireGuardObfuscationUdpOverTcpPort
129-
public let shadowsocksPort: WireGuardObfuscationShadowsockPort
127+
public var state: WireGuardObfuscationState
128+
public var udpOverTcpPort: WireGuardObfuscationUdpOverTcpPort
129+
public var shadowsocksPort: WireGuardObfuscationShadowsockPort
130130

131131
public init(
132132
state: WireGuardObfuscationState = .automatic,

ios/MullvadVPN.xcodeproj/project.pbxproj

+36
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
06799AFC28F98EE300ACD94E /* AddressCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114128F8413A0037AF9A /* AddressCache.swift */; };
4141
0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0697D6E628F01513007A9E99 /* TransportMonitor.swift */; };
4242
06AC116228F94C450037AF9A /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; };
43+
44075DFB2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */; };
44+
440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */; };
45+
440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; };
46+
4422C0712CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */; };
47+
4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; };
4348
449275422C3570CA000526DE /* ICMP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449275412C3570CA000526DE /* ICMP.swift */; };
4449
449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; };
4550
449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; };
@@ -1385,6 +1390,11 @@
13851390
06FAE67A28F83CA50033DD93 /* RESTDevicesProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTDevicesProxy.swift; sourceTree = "<group>"; };
13861391
06FAE67B28F83CA50033DD93 /* REST.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = REST.swift; sourceTree = "<group>"; };
13871392
06FAE67D28F83CA50033DD93 /* RESTTransport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RESTTransport.swift; sourceTree = "<group>"; };
1393+
44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsViewModel.swift; sourceTree = "<group>"; };
1394+
440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = "<group>"; };
1395+
440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = "<group>"; };
1396+
4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsView.swift; sourceTree = "<group>"; };
1397+
4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = "<group>"; };
13881398
449275412C3570CA000526DE /* ICMP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICMP.swift; sourceTree = "<group>"; };
13891399
449275432C3C3029000526DE /* TunnelPinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelPinger.swift; sourceTree = "<group>"; };
13901400
449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = "<group>"; };
@@ -2581,6 +2591,25 @@
25812591
path = Protocols;
25822592
sourceTree = "<group>";
25832593
};
2594+
4422C06F2CCFF6520001A385 /* Obfuscation */ = {
2595+
isa = PBXGroup;
2596+
children = (
2597+
4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */,
2598+
44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */,
2599+
440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */,
2600+
);
2601+
path = Obfuscation;
2602+
sourceTree = "<group>";
2603+
};
2604+
4424CDD12CDBD457009D8C9F /* SwiftUI components */ = {
2605+
isa = PBXGroup;
2606+
children = (
2607+
440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */,
2608+
4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */,
2609+
);
2610+
path = "SwiftUI components";
2611+
sourceTree = "<group>";
2612+
};
25842613
449872E22B7CB91B00094DDC /* MullvadSettings */ = {
25852614
isa = PBXGroup;
25862615
children = (
@@ -2809,6 +2838,8 @@
28092838
583FE01829C19709006E85F9 /* Settings */ = {
28102839
isa = PBXGroup;
28112840
children = (
2841+
4424CDD12CDBD457009D8C9F /* SwiftUI components */,
2842+
4422C06F2CCFF6520001A385 /* Obfuscation */,
28122843
7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */,
28132844
F041BE4E2C983C2B0083EC28 /* DAITASettingsPromptItem.swift */,
28142845
7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */,
@@ -5607,7 +5638,9 @@
56075638
isa = PBXSourcesBuildPhase;
56085639
buildActionMask = 2147483647;
56095640
files = (
5641+
44075DFB2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift in Sources */,
56105642
7A6389DC2B7E3BD6008E77E1 /* CustomListViewModel.swift in Sources */,
5643+
4422C0712CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift in Sources */,
56115644
7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */,
56125645
5827B0A42B0F38FD00CCBBA1 /* EditAccessMethodInteractorProtocol.swift in Sources */,
56135646
586C0D852B03D31E00E7CDD7 /* SocksSectionHandler.swift in Sources */,
@@ -5679,6 +5712,7 @@
56795712
58DFF7D22B0256A300F864E0 /* MarkdownStylingOptions.swift in Sources */,
56805713
5867770E29096984006F721F /* OutOfTimeInteractor.swift in Sources */,
56815714
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */,
5715+
4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */,
56825716
5827B0BD2B14AC9200CCBBA1 /* AccessViewModel+TestingStatus.swift in Sources */,
56835717
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
56845718
58EFC7752AFB4CEF00E9F4CB /* AboutViewController.swift in Sources */,
@@ -5722,6 +5756,7 @@
57225756
7A5869C72B5A8E4C00640D27 /* MethodSettingsDataSourceConfiguration.swift in Sources */,
57235757
58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */,
57245758
5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */,
5759+
440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */,
57255760
F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */,
57265761
7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */,
57275762
58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */,
@@ -5852,6 +5887,7 @@
58525887
58CEB2FD2AFD19D300E6E088 /* UITableView+ReuseIdentifier.swift in Sources */,
58535888
F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */,
58545889
5835B7CC233B76CB0096D79F /* TunnelManager.swift in Sources */,
5890+
440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */,
58555891
588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */,
58565892
5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */,
58575893
588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */,

ios/MullvadVPN/Classes/AccessbilityIdentifier.swift

+1
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ public enum AccessibilityIdentifier: String {
177177
case wireGuardObfuscationUdpOverTcp
178178
case wireGuardObfuscationShadowsocks
179179
case wireGuardPort
180+
case udpTcpObfuscationSettings
180181

181182
// Custom DNS
182183
case blockAll
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// TunnelObfuscationSettingsWatchingObservableObject.swift
3+
// MullvadVPN
4+
//
5+
// Created by Andrew Bulhak on 2024-11-07.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import MullvadSettings
11+
12+
/// a generic ObservableObject that binds to obfuscation settings in TunnelManager.
13+
/// Used as the basis for ViewModels for SwiftUI interfaces for these settings.
14+
15+
class TunnelObfuscationSettingsWatchingObservableObject<T: Equatable>: ObservableObject {
16+
let tunnelManager: TunnelManager
17+
let keyPath: WritableKeyPath<WireGuardObfuscationSettings, T>
18+
private var tunnelObserver: TunnelObserver?
19+
20+
// this is essentially @Published from scratch
21+
var value: T {
22+
willSet(newValue) {
23+
guard newValue != self.value else { return }
24+
objectWillChange.send()
25+
var obfuscationSettings = tunnelManager.settings.wireGuardObfuscation
26+
obfuscationSettings[keyPath: keyPath] = newValue
27+
}
28+
}
29+
30+
init(tunnelManager: TunnelManager, keyPath: WritableKeyPath<WireGuardObfuscationSettings, T>, _ initialValue: T) {
31+
self.tunnelManager = tunnelManager
32+
self.keyPath = keyPath
33+
self.value = initialValue
34+
tunnelObserver =
35+
TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in
36+
guard let self else { return }
37+
updateValueFromSettings(newSettings.wireGuardObfuscation)
38+
})
39+
}
40+
41+
private func updateValueFromSettings(_ settings: WireGuardObfuscationSettings) {
42+
let newValue = settings
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// UDPTCPObfuscationSettingsView.swift
3+
// MullvadVPN
4+
//
5+
// Created by Andrew Bulhak on 2024-10-28.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import MullvadSettings
10+
import SwiftUI
11+
12+
struct UDPTCPObfuscationSettingsView<VM>: View where VM: UDPTCPObfuscationSettingsViewModel {
13+
@StateObject var viewModel: VM
14+
15+
var body: some View {
16+
SingleChoiceList(
17+
title: "Port",
18+
options: [WireGuardObfuscationUdpOverTcpPort.automatic, .port80, .port5001],
19+
value: $viewModel.value
20+
)
21+
}
22+
}
23+
24+
#Preview {
25+
var model = MockUDPTCPObfuscationSettingsViewModel(udpTcpPort: .port5001)
26+
return UDPTCPObfuscationSettingsView(viewModel: model)
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// UDPTCPObfuscationSettingsViewModel.swift
3+
// MullvadVPN
4+
//
5+
// Created by Andrew Bulhak on 2024-11-05.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import MullvadSettings
11+
12+
protocol UDPTCPObfuscationSettingsViewModel: ObservableObject {
13+
var value: WireGuardObfuscationUdpOverTcpPort { get set }
14+
}
15+
16+
/** A simple mock view model for use in Previews and similar */
17+
class MockUDPTCPObfuscationSettingsViewModel: UDPTCPObfuscationSettingsViewModel {
18+
@Published var value: WireGuardObfuscationUdpOverTcpPort
19+
20+
init(udpTcpPort: WireGuardObfuscationUdpOverTcpPort = .automatic) {
21+
self.value = udpTcpPort
22+
}
23+
}
24+
25+
/** The live view model which interfaces with the TunnelManager */
26+
class TunnelUDPTCPObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject<
27+
WireGuardObfuscationUdpOverTcpPort
28+
>,
29+
UDPTCPObfuscationSettingsViewModel {
30+
init(tunnelManager: TunnelManager) {
31+
super.init(
32+
tunnelManager: tunnelManager,
33+
keyPath: \.udpOverTcpPort,
34+
.automatic
35+
)
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// SingleChoiceList.swift
3+
// MullvadVPN
4+
//
5+
// Created by Andrew Bulhak on 2024-11-06.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
11+
/**
12+
A component presenting a vertical list in the Mullvad style for selecting a single item from a list.
13+
The items can be any Hashable type.
14+
*/
15+
16+
struct SingleChoiceList<T>: View where T: Hashable {
17+
let title: String
18+
let options: [T]
19+
var value: Binding<T>
20+
21+
func row(_ v: T) -> some View {
22+
let isSelected = value.wrappedValue == v
23+
return HStack {
24+
Image("IconTick").opacity(isSelected ? 1.0 : 0.0)
25+
Text(verbatim: "\(v)")
26+
Spacer()
27+
}
28+
.padding(16)
29+
.background(isSelected ? Color(UIColor.Cell.Background.selected) : Color(UIColor.Cell.Background.normal))
30+
.foregroundColor(Color(UIColor.Cell.titleTextColor))
31+
.onTapGesture {
32+
value.wrappedValue = v
33+
}
34+
}
35+
36+
var body: some View {
37+
VStack(spacing: 0) {
38+
HStack {
39+
Text(title)
40+
Spacer()
41+
}
42+
.padding(16)
43+
ForEach(options, id: \.self) { opt in
44+
row(opt)
45+
}
46+
Spacer()
47+
}
48+
.background(Color(.secondaryColor))
49+
.foregroundColor(Color(.primaryTextColor))
50+
}
51+
}
52+
53+
#Preview {
54+
StatefulPreviewWrapper(1) { SingleChoiceList(title: "Test", options: [1, 2, 3], value: $0) }
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// StatefulPreviewWrapper.swift
3+
// MullvadVPN
4+
//
5+
// Created by Andrew Bulhak on 2024-11-06.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
// This should probably live somewhere more central than `View controllers/Settings/SwiftUI components`. Where exactly is to be determined.
10+
11+
import SwiftUI
12+
13+
/** A wrapper for providing a state binding for SwiftUI Views in #Preview. This takes as arguments an initial value for the binding and a block which accepts the binding and returns a View to be previewed
14+
The usage looks like:
15+
16+
```
17+
#Preview {
18+
StatefulPreviewWrapper(initvalue) { ComponentToBePreviewed(binding: $0) }
19+
}
20+
```
21+
*/
22+
23+
struct StatefulPreviewWrapper<Value, Content: View>: View {
24+
@State var value: Value
25+
var content: (Binding<Value>) -> Content
26+
27+
var body: some View {
28+
content($value)
29+
}
30+
31+
init(_ value: Value, content: @escaping (Binding<Value>) -> Content) {
32+
self._value = State(wrappedValue: value)
33+
self.content = content
34+
}
35+
}

ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift

+13
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ final class VPNSettingsCellFactory: CellFactoryProtocol {
6666
cell.disclosureType = .chevron
6767
cell.accessibilityIdentifier = item.accessibilityIdentifier
6868

69+
case .udpTcpObfuscationSettings:
70+
guard let cell = cell as? SettingsCell else { return }
71+
72+
cell.titleLabel.text = NSLocalizedString(
73+
"UDP_TCP_OBFUSCATION_CELL_LABEL",
74+
tableName: "VPNSettings",
75+
value: "UDP/TCP Obfuscation",
76+
comment: ""
77+
)
78+
79+
cell.disclosureType = .chevron
80+
cell.accessibilityIdentifier = item.accessibilityIdentifier
81+
6982
case let .wireGuardPort(port):
7083
guard let cell = cell as? SelectableSettingsCell else { return }
7184

0 commit comments

Comments
 (0)