summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2026-02-02 14:58:30 +0100
committerJon Petersson <jon.petersson@mullvad.net>2026-02-02 14:58:30 +0100
commit6f58afd7f2d2bf705942d8d172b1b961375f2e86 (patch)
treebaa89ad53f01071738603e98f303ee373bbc37c8
parent5f842526d785f8538e7ae74543b1c65b5b0b2bba (diff)
downloadmullvadvpn-add-lwo-to-the-relay-selector-and-settings-ios-1453.tar.xz
mullvadvpn-add-lwo-to-the-relay-selector-and-settings-ios-1453.zip
Add LWO to the relay selector and settingsadd-lwo-to-the-relay-selector-and-settings-ios-1453
-rw-r--r--ios/Assets/Localizable.xcstrings3
-rw-r--r--ios/CHANGELOG.md6
-rw-r--r--ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift4
-rw-r--r--ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift62
-rw-r--r--ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift6
-rw-r--r--ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift19
-rw-r--r--ios/MullvadRustRuntime/TunnelObfuscator.swift8
-rw-r--r--ios/MullvadSettings/WireGuardObfuscationSettings.swift35
-rw-r--r--ios/MullvadTypes/ObfuscationMethod.swift10
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj20
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift3
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift50
-rw-r--r--ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift41
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift2
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift24
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift39
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift1
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift10
-rw-r--r--ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift6
-rw-r--r--ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift75
-rw-r--r--ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift54
-rw-r--r--ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift13
-rw-r--r--ios/MullvadVPNUITests/RelayTests.swift99
-rw-r--r--ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift2
24 files changed, 524 insertions, 68 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings
index 371dac37de..d728080143 100644
--- a/ios/Assets/Localizable.xcstrings
+++ b/ios/Assets/Localizable.xcstrings
@@ -33186,6 +33186,9 @@
}
}
},
+ "LWO" : {
+
+ },
"Madrid" : {
"localizations" : {
"da" : {
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 246e5beeef..e2cc58d510 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th
### Add
- Add support for additional languages.
- Add recent connections in the Select location view.
+- Add support for obfuscating WireGuard tunnel traffic by. This helps
+ circumvent censorship.
### Changed
- Improve reliability of the bridge API connection method.
@@ -55,8 +57,8 @@ Line wrap the file at 100 chars. Th
## [2025.6 - 2025-09-23]
### Added
-- Add support for obfuscating WireGuard tunnel traffic as the QUIC protocol. This helps
- circumvent censorship.
+- Add support for obfuscating WireGuard tunnel traffic by LWO (Lightweight WireGuard Obfuscation).
+ This helps circumvent censorship.
- Make feature indicators clickable shortcuts to their corresponding settings.
- Let users cancel sending a problem report.
- Add possibility to manage devices from account view.
diff --git a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift
index aa7e3e412a..dfade50531 100644
--- a/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift
+++ b/ios/MullvadREST/ApiHandlers/ServerRelaysResponse.swift
@@ -89,6 +89,10 @@ extension REST {
!(features?.quic?.addrIn.isEmpty ?? true)
}
+ public var supportsLwo: Bool {
+ features?.lwo != nil
+ }
+
public func override(ipv4AddrIn: IPv4Address?, ipv6AddrIn: IPv6Address?) -> Self {
ServerRelay(
hostname: hostname,
diff --git a/ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift b/ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift
new file mode 100644
index 0000000000..508a292a6a
--- /dev/null
+++ b/ios/MullvadREST/Relay/Obfuscation/LwoObfuscator.swift
@@ -0,0 +1,62 @@
+//
+// LwoObfuscator.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-02.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import MullvadTypes
+
+struct LwoObfuscator: RelayObfuscating {
+ let relays: REST.ServerRelaysResponse
+ let tunnelSettings: LatestTunnelSettings
+ let connectionAttemptCount: UInt
+
+ func obfuscate() -> RelayObfuscation {
+ RelayObfuscation(
+ allRelays: relays,
+ obfuscatedRelays: filterLwoRelays(from: relays),
+ port: validateLwoPort(relays: relays, tunnelSettings: tunnelSettings),
+ method: .lwo
+ )
+ }
+
+ private func filterLwoRelays(from relays: REST.ServerRelaysResponse) -> REST.ServerRelaysResponse {
+ return REST.ServerRelaysResponse(
+ locations: relays.locations,
+ wireguard: REST.ServerWireguardTunnels(
+ ipv4Gateway: relays.wireguard.ipv4Gateway,
+ ipv6Gateway: relays.wireguard.ipv6Gateway,
+ portRanges: relays.wireguard.portRanges,
+ relays: relays.wireguard.relays.filter { $0.supportsLwo },
+ shadowsocksPortRanges: relays.wireguard.shadowsocksPortRanges
+ ),
+ bridge: relays.bridge
+ )
+ }
+
+ private func validateLwoPort(
+ relays: REST.ServerRelaysResponse,
+ tunnelSettings: LatestTunnelSettings
+ ) -> RelayConstraint<UInt16> {
+ guard let customLwoPort = tunnelSettings.wireGuardObfuscation.lwoPort.portValue else {
+ return .any
+ }
+
+ let portIsWithinValidWireGuardRanges = relays.wireguard.portRanges
+ .contains { range in
+ if let minPort = range.first, let maxPort = range.last {
+ return (minPort...maxPort).contains(customLwoPort)
+ }
+ return false
+ }
+
+ if portIsWithinValidWireGuardRanges {
+ return .only(customLwoPort)
+ } else {
+ return .any
+ }
+ }
+}
diff --git a/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift b/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift
index 7abd47f30b..b4d905168d 100644
--- a/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift
+++ b/ios/MullvadREST/Relay/Obfuscation/RelayObfuscator.swift
@@ -55,6 +55,12 @@ struct RelayObfuscator: RelayObfuscating {
tunnelSettings: tunnelSettings,
connectionAttemptCount: connectionAttemptCount
).obfuscate()
+ case .lwo:
+ LwoObfuscator(
+ relays: relays,
+ tunnelSettings: tunnelSettings,
+ connectionAttemptCount: connectionAttemptCount
+ ).obfuscate()
default:
RelayObfuscation(
allRelays: relays,
diff --git a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift
index b82b8ffd95..56a3b16caf 100644
--- a/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift
+++ b/ios/MullvadREST/Relay/RelayPicking/RelayPicking.swift
@@ -76,20 +76,23 @@ extension RelayPicking {
private func resolveObfuscationMethod(features: REST.ServerRelay.Features?) -> ObfuscationMethod {
switch obfuscation.method {
case .off, .automatic:
- return .off
+ .off
case .on:
// `.on` is a legacy state that shouldn't occur in practice
- return .off
+ .off
case .udpOverTcp:
- return .udpOverTcp
+ .udpOverTcp
case .shadowsocks:
- return .shadowsocks
+ .shadowsocks
case .quic:
if let quicFeatures = features?.quic {
- return .quic(hostname: quicFeatures.domain, token: quicFeatures.token)
+ .quic(hostname: quicFeatures.domain, token: quicFeatures.token)
+ } else {
+ // Fall back to off if QUIC features not available
+ .off
}
- // Fall back to off if QUIC features not available
- return .off
+ case .lwo:
+ .lwo
}
}
@@ -99,7 +102,7 @@ extension RelayPicking {
applyShadowsocksIpAddress(in: match)
case .quic:
applyQuicIpAddress(in: match)
- case .off, .automatic, .on, .udpOverTcp:
+ case .off, .automatic, .on, .udpOverTcp, .lwo:
match.endpoint.ipv4Relay.ip
}
}
diff --git a/ios/MullvadRustRuntime/TunnelObfuscator.swift b/ios/MullvadRustRuntime/TunnelObfuscator.swift
index 6f74089265..61cb18f4b3 100644
--- a/ios/MullvadRustRuntime/TunnelObfuscator.swift
+++ b/ios/MullvadRustRuntime/TunnelObfuscator.swift
@@ -15,6 +15,7 @@ public enum TunnelObfuscationProtocol {
case udpOverTcp
case shadowsocks
case quic(hostname: String, token: String)
+ case lwo
}
public protocol TunnelObfuscation {
@@ -52,9 +53,7 @@ public final class TunnelObfuscator: TunnelObfuscation {
switch obfuscationProtocol {
case .udpOverTcp:
.tcp
- case .shadowsocks:
- .udp
- case .quic:
+ case .shadowsocks, .quic, .lwo:
.udp
}
}
@@ -101,6 +100,9 @@ public final class TunnelObfuscator: TunnelObfuscation {
token,
proxyHandlePointer
)
+ case .lwo:
+ // Todo: Add obfuscator proxy
+ Int32(0)
}
}
diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift
index cdfc8dd0e9..efc72d56eb 100644
--- a/ios/MullvadSettings/WireGuardObfuscationSettings.swift
+++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift
@@ -20,6 +20,7 @@ public enum WireGuardObfuscationState: Codable, Sendable {
case udpOverTcp
case shadowsocks
case quic
+ case lwo
case off
public init(from decoder: Decoder) throws {
@@ -46,13 +47,15 @@ public enum WireGuardObfuscationState: Codable, Sendable {
self = .shadowsocks
case .quic:
self = .quic
+ case .lwo:
+ self = .lwo
case .off:
self = .off
}
}
public var isEnabled: Bool {
- [.udpOverTcp, .shadowsocks, .quic].contains(self)
+ [.udpOverTcp, .shadowsocks, .quic, .lwo].contains(self)
}
}
@@ -112,6 +115,29 @@ public enum WireGuardObfuscationShadowsocksPort: Codable, Equatable, CustomStrin
}
}
+public enum WireGuardObfuscationLwoPort: Codable, Equatable, CustomStringConvertible, Sendable {
+ case automatic
+ case custom(UInt16)
+
+ public var portValue: UInt16? {
+ switch self {
+ case .automatic:
+ nil
+ case let .custom(port):
+ port
+ }
+ }
+
+ public var description: String {
+ switch self {
+ case .automatic:
+ NSLocalizedString("Automatic", comment: "")
+ case let .custom(port):
+ String(port)
+ }
+ }
+}
+
// Can't deprecate the whole type since it'll yield a lint warning when decoding
// port in `WireGuardObfuscationSettings`.
private enum WireGuardObfuscationPort: UInt16, Codable, Sendable {
@@ -130,21 +156,26 @@ public struct WireGuardObfuscationSettings: Codable, Equatable, Sendable {
public var state: WireGuardObfuscationState
public var udpOverTcpPort: WireGuardObfuscationUdpOverTcpPort
public var shadowsocksPort: WireGuardObfuscationShadowsocksPort
+ public var lwoPort: WireGuardObfuscationLwoPort
public init(
state: WireGuardObfuscationState = .automatic,
udpOverTcpPort: WireGuardObfuscationUdpOverTcpPort = .automatic,
- shadowsocksPort: WireGuardObfuscationShadowsocksPort = .automatic
+ shadowsocksPort: WireGuardObfuscationShadowsocksPort = .automatic,
+ lwoPort: WireGuardObfuscationLwoPort = .automatic
) {
self.state = state
self.udpOverTcpPort = udpOverTcpPort
self.shadowsocksPort = shadowsocksPort
+ self.lwoPort = lwoPort
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
state = try container.decode(WireGuardObfuscationState.self, forKey: .state)
+ lwoPort = try container.decode(WireGuardObfuscationLwoPort.self, forKey: .lwoPort)
+
shadowsocksPort =
try container.decodeIfPresent(
WireGuardObfuscationShadowsocksPort.self,
diff --git a/ios/MullvadTypes/ObfuscationMethod.swift b/ios/MullvadTypes/ObfuscationMethod.swift
index a0b9ae4fcd..88e3794312 100644
--- a/ios/MullvadTypes/ObfuscationMethod.swift
+++ b/ios/MullvadTypes/ObfuscationMethod.swift
@@ -14,13 +14,5 @@ public enum ObfuscationMethod: Equatable, Codable, Sendable {
case udpOverTcp
case shadowsocks
case quic(hostname: String, token: String)
-
- public var isEnabled: Bool {
- switch self {
- case .off:
- false
- case .udpOverTcp, .shadowsocks, .quic:
- true
- }
- }
+ case lwo
}
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 45b6991e67..dedad28af8 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -590,7 +590,6 @@
7A9BE5A92B90806800E2A7D0 /* CustomListsRepositoryStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */; };
7A9BE5AB2B909A1700E2A7D0 /* LocationDataSourceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050AE5D2B739A73003F4EDB /* LocationDataSourceProtocol.swift */; };
7A9BE5AD2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */; };
- 87A647B96EC28D42434F4D03 /* AllLocationDataSourceBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */; };
7A9CCCB32A96302800DD6A34 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */; };
7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */; };
7A9CCCB72A96302800DD6A34 /* RevokedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCA52A96302700DD6A34 /* RevokedCoordinator.swift */; };
@@ -617,6 +616,10 @@
7AA1309F2D007B2500640DF9 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */; };
7AA130A12D01B1E200640DF9 /* SplitMainButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */; };
7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; };
+ 7AA5644C2F30C789001D1FB9 /* LwoObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5644B2F30C779001D1FB9 /* LwoObfuscationSettingsView.swift */; };
+ 7AA5644E2F30C7B3001D1FB9 /* LwoObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5644D2F30C7A6001D1FB9 /* LwoObfuscationSettingsViewModel.swift */; };
+ 7AA564502F30D0DE001D1FB9 /* LwoObfuscator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5644F2F30D0D9001D1FB9 /* LwoObfuscator.swift */; };
+ 7AA564522F30E033001D1FB9 /* LwoObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA564512F30E02C001D1FB9 /* LwoObfuscationSettingsPage.swift */; };
7AA5C3702E9D21DB00B35530 /* DefaultLocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5C36F2E9D21CD00B35530 /* DefaultLocationService.swift */; };
7AA5C3752E9FCD9300B35530 /* DefaultLocationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5C3742E9FCD5F00B35530 /* DefaultLocationServiceTests.swift */; };
7AA5C3792E9FD8BA00B35530 /* URLSessionStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BA2AE95396003D4F89 /* URLSessionStub.swift */; };
@@ -726,6 +729,7 @@
85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */; };
85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0B2B6903990015DCED /* WelcomePage.swift */; };
85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */; };
+ 87A647B96EC28D42434F4D03 /* AllLocationDataSourceBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */; };
A902E7A62D3FB0D9007F844A /* LogFileOutputStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902E7A52D3FB0D9007F844A /* LogFileOutputStreamTests.swift */; };
A90C48672C36BC2600DCB94C /* EphemeralPeerReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90C48662C36BC2600DCB94C /* EphemeralPeerReceiver.swift */; };
A90C48692C36BF3900DCB94C /* TunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90C48682C36BF3900DCB94C /* TunnelProvider.swift */; };
@@ -2147,7 +2151,6 @@
7A9BE5A42B90760C00E2A7D0 /* CustomListsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsDataSourceTests.swift; sourceTree = "<group>"; };
7A9BE5A82B90806800E2A7D0 /* CustomListsRepositoryStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomListsRepositoryStub.swift; sourceTree = "<group>"; };
7A9BE5AC2B90DF2D00E2A7D0 /* AllLocationsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationsDataSourceTests.swift; sourceTree = "<group>"; };
- 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationDataSourceBenchmarkTests.swift; sourceTree = "<group>"; };
7A9CCCA12A96302700DD6A34 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA22A96302700DD6A34 /* TermsOfServiceCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TermsOfServiceCoordinator.swift; sourceTree = "<group>"; };
7A9CCCA42A96302700DD6A34 /* OutOfTimeCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeCoordinator.swift; sourceTree = "<group>"; };
@@ -2175,6 +2178,10 @@
7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = "<group>"; };
7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitMainButton.swift; sourceTree = "<group>"; };
7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = "<group>"; };
+ 7AA5644B2F30C779001D1FB9 /* LwoObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscationSettingsView.swift; sourceTree = "<group>"; };
+ 7AA5644D2F30C7A6001D1FB9 /* LwoObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscationSettingsViewModel.swift; sourceTree = "<group>"; };
+ 7AA5644F2F30D0D9001D1FB9 /* LwoObfuscator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscator.swift; sourceTree = "<group>"; };
+ 7AA564512F30E02C001D1FB9 /* LwoObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LwoObfuscationSettingsPage.swift; sourceTree = "<group>"; };
7AA5C36F2E9D21CD00B35530 /* DefaultLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocationService.swift; sourceTree = "<group>"; };
7AA5C3742E9FCD5F00B35530 /* DefaultLocationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLocationServiceTests.swift; sourceTree = "<group>"; };
7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditionals.swift"; sourceTree = "<group>"; };
@@ -2279,6 +2286,7 @@
85F1E17D2C0A256200DB8F55 /* LeakTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakTests.swift; sourceTree = "<group>"; };
85FB5A0B2B6903990015DCED /* WelcomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage.swift; sourceTree = "<group>"; };
85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionPage.swift; sourceTree = "<group>"; };
+ 87A647B96EC28D42434F4D02 /* AllLocationDataSourceBenchmarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllLocationDataSourceBenchmarkTests.swift; sourceTree = "<group>"; };
A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = "<group>"; };
A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTRequestExecutor+Stubs.swift"; sourceTree = "<group>"; };
A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DevicesProxy+Stubs.swift"; sourceTree = "<group>"; };
@@ -2974,6 +2982,8 @@
4422C06F2CCFF6520001A385 /* Obfuscation */ = {
isa = PBXGroup;
children = (
+ 7AA5644B2F30C779001D1FB9 /* LwoObfuscationSettingsView.swift */,
+ 7AA5644D2F30C7A6001D1FB9 /* LwoObfuscationSettingsViewModel.swift */,
447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */,
447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */,
440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */,
@@ -4399,6 +4409,7 @@
7A964BBB2E699B4300C6A4EC /* Obfuscation */ = {
isa = PBXGroup;
children = (
+ 7AA5644F2F30D0D9001D1FB9 /* LwoObfuscator.swift */,
7A964BBA2E699B3000C6A4EC /* QuicObfuscator.swift */,
7AD63A382CD520FD00445268 /* RelayObfuscator.swift */,
7A964BB72E6999A500C6A4EC /* ShadowsocksObfuscator.swift */,
@@ -4532,6 +4543,7 @@
85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */,
A998DA802BD147AD001D61A2 /* ListCustomListsPage.swift */,
852969342B4E9270007EAD4C /* LoginPage.swift */,
+ 7AA564512F30E02C001D1FB9 /* LwoObfuscationSettingsPage.swift */,
7A8A19232CF4C9B8000BCB5B /* MultihopPage.swift */,
85139B2C2B84B4A700734217 /* OutOfTimePage.swift */,
852969322B4E9232007EAD4C /* Page.swift */,
@@ -5909,6 +5921,7 @@
7A2E7B752D6CA0B1009EF2C3 /* APITransportProvider.swift in Sources */,
F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */,
7AD63A442CDA663300445268 /* UInt+Counting.swift in Sources */,
+ 7AA564502F30D0DE001D1FB9 /* LwoObfuscator.swift in Sources */,
7A516C3A2B7111A700BBD33D /* IPOverrideWrapper.swift in Sources */,
7A964BB92E699A3F00C6A4EC /* ShadowsocksObfuscator.swift in Sources */,
7ACE19112C1C349200260BB6 /* MultihopDecisionFlow.swift in Sources */,
@@ -6533,6 +6546,7 @@
7A1A26452A29CEF700B978AA /* RelayFilterViewController.swift in Sources */,
F947DA002ED5AC9200C9D728 /* CGRect+Properties.swift in Sources */,
587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */,
+ 7AA5644C2F30C789001D1FB9 /* LwoObfuscationSettingsView.swift in Sources */,
5888AD83227B11080051EB06 /* LocationCell.swift in Sources */,
5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */,
5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */,
@@ -6669,6 +6683,7 @@
7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */,
58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */,
7A5869C12B57D21A00640D27 /* IPOverrideStatusView.swift in Sources */,
+ 7AA5644E2F30C7B3001D1FB9 /* LwoObfuscationSettingsViewModel.swift in Sources */,
F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */,
58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */,
58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */,
@@ -6909,6 +6924,7 @@
850201DB2B503D7700EF8C96 /* RelayTests.swift in Sources */,
7A9F29352CAA8829005F2089 /* AccessMethodsTests.swift in Sources */,
7A45CFC62C05FF6A00D80B21 /* ScreenshotTests.swift in Sources */,
+ 7AA564522F30E033001D1FB9 /* LwoObfuscationSettingsPage.swift in Sources */,
852D054D2BC3DE3A008578D2 /* APIAccessPage.swift in Sources */,
85139B2D2B84B4A700734217 /* OutOfTimePage.swift in Sources */,
852969362B4E9724007EAD4C /* AccessbilityIdentifier.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
index 2f4d127bce..8055c68421 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -212,8 +212,10 @@ public enum AccessibilityIdentifier: Equatable {
case wireGuardObfuscationUdpOverTcp
case wireGuardObfuscationShadowsocks
case wireGuardObfuscationQuic
+ case wireGuardObfuscationLwo
case wireGuardObfuscationUdpOverTcpPort
case wireGuardObfuscationShadowsocksPort
+ case wireGuardObfuscationLwoPort
case wireGuardPort(UInt16?)
case udpOverTcpObfuscationSettings
@@ -246,6 +248,7 @@ public enum AccessibilityIdentifier: Equatable {
// WireGuard obfuscation settings
case wireGuardObfuscationUdpOverTcpTable
case wireGuardObfuscationShadowsocksTable
+ case wireGuardObfuscationLwoTable
// Error
case unknown
diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift
new file mode 100644
index 0000000000..6d53e95c36
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsView.swift
@@ -0,0 +1,50 @@
+//
+// LwoObfuscationSettingsView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-02.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import SwiftUI
+
+struct LwoObfuscationSettingsView<VM>: View where VM: LwoObfuscationSettingsViewModel {
+ @StateObject var viewModel: VM
+
+ var body: some View {
+ let portString = NSLocalizedString("Port", comment: "")
+
+ SingleChoiceList(
+ title: portString,
+ options: [WireGuardObfuscationLwoPort.automatic],
+ value: $viewModel.value,
+ tableAccessibilityIdentifier: AccessibilityIdentifier.wireGuardObfuscationLwoTable.asString,
+ itemDescription: { item in NSLocalizedString("\(item)", comment: "") },
+ parseCustomValue: {
+ UInt16($0).flatMap { $0 > 0 ? WireGuardObfuscationLwoPort.custom($0) : nil }
+ },
+ formatCustomValue: {
+ if case let .custom(port) = $0 {
+ "\(port)"
+ } else {
+ nil
+ }
+ },
+ customLabel: NSLocalizedString("Custom", comment: ""),
+ customPrompt: NSLocalizedString("Port", comment: ""),
+ customLegend: String(
+ format: NSLocalizedString("Valid range: %d - %d", comment: ""), arguments: [1, 65535]),
+ customInputMinWidth: 100,
+ customInputMaxLength: 5,
+ customFieldMode: .numericText
+ ).onDisappear {
+ viewModel.commit()
+ }
+ }
+}
+
+#Preview {
+ let model = MockLwoObfuscationSettingsViewModel(lwoPort: .automatic)
+ return LwoObfuscationSettingsView(viewModel: model)
+}
diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift
new file mode 100644
index 0000000000..92eed38d27
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/LwoObfuscationSettingsViewModel.swift
@@ -0,0 +1,41 @@
+//
+// LwoObfuscationSettingsViewModel.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-02.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadSettings
+
+protocol LwoObfuscationSettingsViewModel: ObservableObject {
+ var value: WireGuardObfuscationLwoPort { get set }
+
+ func commit()
+}
+
+/** A simple mock view model for use in Previews and similar */
+class MockLwoObfuscationSettingsViewModel: LwoObfuscationSettingsViewModel {
+ @Published var value: WireGuardObfuscationLwoPort
+
+ init(lwoPort: WireGuardObfuscationLwoPort = .automatic) {
+ self.value = lwoPort
+ }
+
+ func commit() {}
+}
+
+/// ** The live view model which interfaces with the TunnelManager */
+class TunnelLwoObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject<
+ WireGuardObfuscationLwoPort
+>,
+LwoObfuscationSettingsViewModel
+{
+ init(tunnelManager: TunnelManager) {
+ super.init(
+ tunnelManager: tunnelManager,
+ keyPath: \.lwoPort
+ )
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift
index 0fcb5fc0c4..8844fb6937 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipFeature.swift
@@ -82,7 +82,7 @@ struct ObfuscationFeature: ChipFeature {
}
var isEnabled: Bool {
- actualObfuscationMethod.isEnabled
+ settings.wireGuardObfuscation.state.isEnabled
}
var isAutomatic: Bool {
diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift
index 91a873e7ed..6807849519 100644
--- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift
+++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsCellFactory.swift
@@ -178,19 +178,29 @@ final class VPNSettingsCellFactory: @preconcurrency CellFactoryProtocol {
cell.detailTitleLabel.setAccessibilityIdentifier(.wireGuardObfuscationQuic)
cell.applySubCellStyling()
- case .wireGuardObfuscationOff:
- guard let cell = cell as? SelectableSettingsCell else { return }
+ case .wireGuardObfuscationLwo:
+ guard let cell = cell as? SelectableSettingsDetailsCell else { return }
+
+ cell.titleLabel.text = NSLocalizedString("LWO", comment: "")
+
+ cell.detailTitleLabel.text = String(
+ format: NSLocalizedString("Port: %@", comment: ""),
+ viewModel.obfuscationLwoPort.description
+ )
- cell.titleLabel.text = NSLocalizedString("Off", comment: "")
cell.setAccessibilityIdentifier(item.accessibilityIdentifier)
+ cell.detailTitleLabel.setAccessibilityIdentifier(.wireGuardObfuscationLwoPort)
cell.applySubCellStyling()
- case let .wireGuardObfuscationPort(port):
+ cell.buttonAction = { [weak self] in
+ self?.delegate?.showDetails(for: .lwo)
+ }
+
+ case .wireGuardObfuscationOff:
guard let cell = cell as? SelectableSettingsCell else { return }
- let portString = port.description
- cell.titleLabel.text = portString
- cell.accessibilityIdentifier = "\(item.accessibilityIdentifier)\(portString)"
+ cell.titleLabel.text = NSLocalizedString("Off", comment: "")
+ cell.setAccessibilityIdentifier(item.accessibilityIdentifier)
cell.applySubCellStyling()
case .quantumResistanceAutomatic:
diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift
index 5331b9b4e9..11d035e85d 100644
--- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift
+++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift
@@ -85,8 +85,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
case wireGuardObfuscationUdpOverTcp
case wireGuardObfuscationShadowsocks
case wireGuardObfuscationQuic
+ case wireGuardObfuscationLwo
case wireGuardObfuscationOff
- case wireGuardObfuscationPort(_ port: WireGuardObfuscationUdpOverTcpPort)
case quantumResistanceAutomatic
case quantumResistanceOn
case quantumResistanceOff
@@ -99,22 +99,19 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
}
static var wireGuardObfuscation: [Item] {
- [
+ var items: [Item] = [
.wireGuardObfuscationAutomatic,
.wireGuardObfuscationShadowsocks,
.wireGuardObfuscationUdpOverTcp,
.wireGuardObfuscationQuic,
.wireGuardObfuscationOff,
]
- }
- static var wireGuardObfuscationPort: [Item] {
- [
- .wireGuardObfuscationPort(.automatic),
- .wireGuardObfuscationPort(.port80),
- .wireGuardObfuscationPort(.port443),
- .wireGuardObfuscationPort(.port5001),
- ]
+ #if DEBUG
+ items.insert(.wireGuardObfuscationLwo, at: 3)
+ #endif
+
+ return items
}
static var quantumResistance: [Item] {
@@ -143,10 +140,10 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
.wireGuardObfuscationShadowsocks
case .wireGuardObfuscationQuic:
.wireGuardObfuscationQuic
+ case .wireGuardObfuscationLwo:
+ .wireGuardObfuscationLwo
case .wireGuardObfuscationOff:
.wireGuardObfuscationOff
- case .wireGuardObfuscationPort:
- .wireGuardObfuscationPort
case .quantumResistanceAutomatic:
.quantumResistanceAutomatic
case .quantumResistanceOn:
@@ -172,10 +169,8 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
.wireGuardCustomPort
case .wireGuardObfuscationAutomatic, .wireGuardObfuscationOff, .wireGuardObfuscationQuic:
.wireGuardObfuscation
- case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks:
+ case .wireGuardObfuscationUdpOverTcp, .wireGuardObfuscationShadowsocks, .wireGuardObfuscationLwo:
.wireGuardObfuscationOption
- case .wireGuardObfuscationPort:
- .wireGuardObfuscationPort
case .quantumResistanceAutomatic, .quantumResistanceOn, .quantumResistanceOff:
.quantumResistance
}
@@ -215,6 +210,7 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
case .on, .udpOverTcp: .wireGuardObfuscationUdpOverTcp
case .shadowsocks: .wireGuardObfuscationShadowsocks
case .quic: .wireGuardObfuscationQuic
+ case .lwo: .wireGuardObfuscationLwo
}
let quantumResistanceItem: Item =
@@ -224,12 +220,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
case .on: .quantumResistanceOn
}
- let obfuscationPortItem: Item = .wireGuardObfuscationPort(viewModel.obfuscationUpdOverTcpPort)
-
return [
wireGuardPortItem,
obfuscationStateItem,
- obfuscationPortItem,
quantumResistanceItem,
].compactMap { indexPath(for: $0) }
}
@@ -366,12 +359,12 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource<
case .wireGuardObfuscationQuic:
selectObfuscationState(.quic)
delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings))
+ case .wireGuardObfuscationLwo:
+ selectObfuscationState(.lwo)
+ delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings))
case .wireGuardObfuscationOff:
selectObfuscationState(.off)
delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings))
- case let .wireGuardObfuscationPort(port):
- selectObfuscationPort(port)
- delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings))
case .quantumResistanceAutomatic:
selectQuantumResistance(.automatic)
delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.quantumResistance(viewModel.quantumResistance))
@@ -727,10 +720,6 @@ extension VPNSettingsDataSource: @preconcurrency VPNSettingsCellEventHandler {
viewModel.setWireGuardObfuscationState(state)
}
- func selectObfuscationPort(_ port: WireGuardObfuscationUdpOverTcpPort) {
- viewModel.setWireGuardObfuscationUdpOverTcpPort(port)
- }
-
func selectQuantumResistance(_ state: TunnelQuantumResistance) {
viewModel.setQuantumResistance(state)
}
diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift
index f3f19431fd..5c7c0e7a0d 100644
--- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift
+++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDetailsButtonItem.swift
@@ -9,4 +9,5 @@
enum VPNSettingsDetailsButtonItem {
case udpOverTcp
case wireguardOverShadowsocks
+ case lwo
}
diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift
index 3233f4c343..5b541de41c 100644
--- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift
+++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift
@@ -120,6 +120,8 @@ extension VPNSettingsViewController: @preconcurrency VPNSettingsDataSourceDelega
showUDPOverTCPObfuscationSettings()
case .wireguardOverShadowsocks:
showShadowsocksObfuscationSettings()
+ case .lwo:
+ showLwoObfuscationSettings()
}
}
@@ -148,6 +150,14 @@ extension VPNSettingsViewController: @preconcurrency VPNSettingsDataSourceDelega
navigationController?.pushViewController(vc, animated: true)
}
+ private func showLwoObfuscationSettings() {
+ let viewModel = TunnelLwoObfuscationSettingsViewModel(tunnelManager: interactor.tunnelManager)
+ let view = LwoObfuscationSettingsView(viewModel: viewModel)
+ let vc = UIHostingController(rootView: view)
+ vc.title = NSLocalizedString("LWO", comment: "")
+ navigationController?.pushViewController(vc, animated: true)
+ }
+
func didSelectWireGuardPort(_ port: UInt16?) {
interactor.setPort(port)
}
diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift
index a37397a2dd..0d4f8adbb5 100644
--- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift
+++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewModel.swift
@@ -90,6 +90,7 @@ struct VPNSettingsViewModel: Equatable {
private(set) var obfuscationState: WireGuardObfuscationState
private(set) var obfuscationUpdOverTcpPort: WireGuardObfuscationUdpOverTcpPort
private(set) var obfuscationShadowsocksPort: WireGuardObfuscationShadowsocksPort
+ private(set) var obfuscationLwoPort: WireGuardObfuscationLwoPort
private(set) var quantumResistance: TunnelQuantumResistance
private(set) var multihopState: MultihopState
@@ -181,6 +182,10 @@ struct VPNSettingsViewModel: Equatable {
obfuscationUpdOverTcpPort = newPort
}
+ mutating func setWireGuardObfuscationLwoPort(_ newPort: WireGuardObfuscationLwoPort) {
+ obfuscationLwoPort = newPort
+ }
+
mutating func setQuantumResistance(_ newState: TunnelQuantumResistance) {
quantumResistance = newState
}
@@ -251,6 +256,7 @@ struct VPNSettingsViewModel: Equatable {
obfuscationState = tunnelSettings.wireGuardObfuscation.state
obfuscationUpdOverTcpPort = tunnelSettings.wireGuardObfuscation.udpOverTcpPort
obfuscationShadowsocksPort = tunnelSettings.wireGuardObfuscation.shadowsocksPort
+ obfuscationLwoPort = tunnelSettings.wireGuardObfuscation.lwoPort
quantumResistance = tunnelSettings.tunnelQuantumResistance
multihopState = tunnelSettings.tunnelMultihopState
diff --git a/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift b/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift
index 99e4f8c5ca..2bf50c4cf0 100644
--- a/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift
+++ b/ios/MullvadVPNTests/MullvadREST/Relay/RelayObfuscatorTests.swift
@@ -30,7 +30,8 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
XCTAssertEqual(obfuscationResult.port, defaultWireguardPort)
@@ -134,7 +135,8 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: UInt(attempt), obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: UInt(attempt),
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
let validPorts: [RelayConstraint<UInt16>] = [.only(80), .only(443), .only(5001)]
@@ -153,7 +155,8 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
XCTAssertEqual(obfuscationResult.port, .only(5500))
@@ -168,7 +171,8 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
let portRanges = RelaySelector.parseRawPortRanges(sampleRelays.wireguard.shadowsocksPortRanges)
@@ -199,7 +203,8 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
let relaysWithExtraAddresses = sampleRelays.wireguard.relays.filter { relay in
@@ -221,7 +226,8 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
XCTAssertEqual(obfuscationResult.obfuscatedRelays.wireguard.relays.count, sampleRelays.wireguard.relays.count)
@@ -237,12 +243,67 @@ final class RelayObfuscatorTests: XCTestCase {
let obfuscationResult = RelayObfuscator(
relays: sampleRelays,
tunnelSettings: tunnelSettings,
- connectionAttemptCount: 0, obfuscationBypass: IdentityObfuscationProvider()
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
).obfuscate()
XCTAssertEqual(obfuscationResult.port, defaultQuicPort)
}
+ // MARK: LWO
+
+ func testObfuscateLwo() throws {
+ tunnelSettings.wireGuardObfuscation = WireGuardObfuscationSettings(
+ state: .lwo,
+ lwoPort: .custom(4000)
+ )
+
+ let obfuscationResult = RelayObfuscator(
+ relays: sampleRelays,
+ tunnelSettings: tunnelSettings,
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
+ ).obfuscate()
+
+ let relaysWithLwoSupport = sampleRelays.wireguard.relays.filter { relay in
+ relay.supportsLwo
+ }
+
+ XCTAssertEqual(obfuscationResult.obfuscatedRelays.wireguard.relays.count, relaysWithLwoSupport.count)
+ }
+
+ func testObfuscateLwoPortCustom() throws {
+ tunnelSettings.wireGuardObfuscation = WireGuardObfuscationSettings(
+ state: .lwo,
+ lwoPort: .custom(4000)
+ )
+
+ let obfuscationResult = RelayObfuscator(
+ relays: sampleRelays,
+ tunnelSettings: tunnelSettings,
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
+ ).obfuscate()
+
+ XCTAssertEqual(obfuscationResult.port, .only(4000))
+ }
+
+ func testObfuscateLwoPortCustomOutsideRange() throws {
+ tunnelSettings.wireGuardObfuscation = WireGuardObfuscationSettings(
+ state: .lwo,
+ lwoPort: .custom(1)
+ )
+
+ let obfuscationResult = RelayObfuscator(
+ relays: sampleRelays,
+ tunnelSettings: tunnelSettings,
+ connectionAttemptCount: 0,
+ obfuscationBypass: IdentityObfuscationProvider()
+ ).obfuscate()
+
+ XCTAssertEqual(obfuscationResult.port, .any)
+ }
+
// MARK: Obfuscation Bypass
func testObfuscatorBypass() throws {
diff --git a/ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift b/ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift
new file mode 100644
index 0000000000..7fbcf0104c
--- /dev/null
+++ b/ios/MullvadVPNUITests/Pages/LwoObfuscationSettingsPage.swift
@@ -0,0 +1,54 @@
+//
+// LwoObfuscationSettingsPage.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-02.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import XCTest
+
+class LwoObfuscationSettingsPage: Page {
+ @discardableResult override init(_ app: XCUIApplication) {
+ super.init(app)
+ }
+
+ private var table: XCUIElement {
+ app.collectionViews[AccessibilityIdentifier.wireGuardObfuscationLwoTable]
+ }
+
+ private func portCell(_ index: Int) -> XCUIElement {
+ table.cells.element(boundBy: index)
+ }
+
+ private var customCell: XCUIElement {
+ // assumption: the last cell is the legend
+ table.cells.allElementsBoundByIndex.dropLast().last!
+ }
+
+ private var customTextField: XCUIElement {
+ customCell.textFields.firstMatch
+ }
+
+ @discardableResult func tapAutomaticPortCell() -> Self {
+ portCell(0).tap()
+ return self
+ }
+
+ @discardableResult func tapCustomCell() -> Self {
+ customCell.tap()
+ return self
+ }
+
+ @discardableResult func typeTextIntoCustomField(_ text: String) -> Self {
+ customTextField.typeText(text)
+ return self
+ }
+
+ @discardableResult func tapBackButton() -> Self {
+ // Workaround for setting accessibility identifier on navigation bar button being non-trivial
+ app.navigationBars.buttons.element(boundBy: 0).tap()
+ return self
+ }
+}
diff --git a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift
index 2bb9e8a014..18f79ab212 100644
--- a/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift
+++ b/ios/MullvadVPNUITests/Pages/VPNSettingsPage.swift
@@ -86,6 +86,12 @@ class VPNSettingsPage: Page {
return self
}
+ @discardableResult func tapLwoPortSelectorButton() -> Self {
+ cellPortSelectorButton(AccessibilityIdentifier.wireGuardObfuscationLwo).tap()
+
+ return self
+ }
+
@discardableResult func tapQuantumResistantTunnelExpandButton() -> Self {
cellExpandButton(AccessibilityIdentifier.quantumResistantTunnelCell).tap()
@@ -128,11 +134,16 @@ class VPNSettingsPage: Page {
return self
}
- @discardableResult func tapWireGuardObufscationQuicCell() -> Self {
+ @discardableResult func tapWireGuardObfuscationQuicCell() -> Self {
app.cells[AccessibilityIdentifier.wireGuardObfuscationQuic].tap()
return self
}
+ @discardableResult func tapWireGuardObfuscationLwoCell() -> Self {
+ app.cells[AccessibilityIdentifier.wireGuardObfuscationLwo].tap()
+ return self
+ }
+
@discardableResult func tapWireGuardObfuscationOffCell() -> Self {
app.cells[AccessibilityIdentifier.wireGuardObfuscationOff].tap()
diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift
index 5ea75d12ae..fd4f75665b 100644
--- a/ios/MullvadVPNUITests/RelayTests.swift
+++ b/ios/MullvadVPNUITests/RelayTests.swift
@@ -241,6 +241,62 @@ class RelayTests: LoggedInWithTimeUITestCase {
try generateTrafficAndDisconnect(from: connectedToIPAddress, searchForPort: 51900, assertProtocol: .UDP)
}
+ func testWireGuardOverLwoCustomPort() throws {
+ addTeardownBlock {
+ HeaderBar(self.app)
+ .tapSettingsButton()
+
+ SettingsPage(self.app)
+ .tapVPNSettingsCell()
+
+ VPNSettingsPage(self.app)
+ .tapWireGuardObfuscationExpandButton()
+ .tapWireGuardObfuscationOffCell()
+ }
+
+ HeaderBar(app)
+ .tapSettingsButton()
+
+ SettingsPage(app)
+ .tapVPNSettingsCell()
+
+ VPNSettingsPage(app)
+ .tapWireGuardObfuscationExpandButton()
+ .tapWireGuardObfuscationLwoCell()
+ .tapLwoPortSelectorButton()
+
+ LwoObfuscationSettingsPage(app)
+ .tapCustomCell()
+ .typeTextIntoCustomField("1")
+ .tapBackButton()
+
+ VPNSettingsPage(app)
+ .tapBackButton()
+
+ SettingsPage(app)
+ .tapDoneButton()
+
+ // The packet capture has to start before the tunnel is up,
+ // otherwise the device cannot reach the in-house router anymore
+ startPacketCapture()
+
+ TunnelControlPage(app)
+ .tapConnectButton()
+
+ allowAddVPNConfigurationsIfAsked()
+
+ TunnelControlPage(app)
+ .waitForConnectedLabel()
+
+ let (connectedToIPAddress, _) = TunnelControlPage(app)
+ .tapRelayStatusExpandCollapseButton()
+ .getInIPAddressAndPortFromConnectionStatus()
+
+ try Networking.verifyCanAccessInternet()
+
+ try generateTrafficAndDisconnect(from: connectedToIPAddress, searchForPort: 1, assertProtocol: .UDP)
+ }
+
func testWireGuardOverTCPManually() throws {
addTeardownBlock {
HeaderBar(self.app)
@@ -346,7 +402,7 @@ class RelayTests: LoggedInWithTimeUITestCase {
VPNSettingsPage(app)
.tapWireGuardObfuscationExpandButton()
- .tapWireGuardObufscationQuicCell()
+ .tapWireGuardObfuscationQuicCell()
.tapBackButton()
SettingsPage(app)
@@ -395,6 +451,47 @@ class RelayTests: LoggedInWithTimeUITestCase {
try generateTrafficAndDisconnect(from: connectedToIPAddress, searchForPort: 443, assertProtocol: .UDP)
}
+ func testWireGuardOverLwoManually() throws {
+ addTeardownBlock {
+ HeaderBar(self.app)
+ .tapSettingsButton()
+
+ SettingsPage(self.app)
+ .tapVPNSettingsCell()
+
+ VPNSettingsPage(self.app)
+ .tapWireGuardObfuscationExpandButton()
+ .tapWireGuardObfuscationOffCell()
+ }
+
+ HeaderBar(app)
+ .tapSettingsButton()
+
+ SettingsPage(app)
+ .tapVPNSettingsCell()
+
+ VPNSettingsPage(app)
+ .tapWireGuardObfuscationExpandButton()
+ .tapWireGuardObfuscationLwoCell()
+ .tapBackButton()
+
+ SettingsPage(app)
+ .tapDoneButton()
+
+ TunnelControlPage(app)
+ .tapConnectButton()
+
+ allowAddVPNConfigurationsIfAsked()
+
+ TunnelControlPage(app)
+ .waitForConnectedLabel()
+
+ try Networking.verifyCanAccessInternet()
+
+ TunnelControlPage(app)
+ .tapDisconnectButton()
+ }
+
/// Test automatic switching to TCP is functioning when UDP traffic to relays is blocked.
func testWireGuardOverTCPAutomatically() throws {
FirewallClient().removeRules()
diff --git a/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift b/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift
index a106a91d02..549fe336c0 100644
--- a/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift
+++ b/ios/PacketTunnelCore/Actor/ProtocolObfuscator.swift
@@ -55,6 +55,8 @@ public class ProtocolObfuscator<Obfuscator: TunnelObfuscation>: ProtocolObfuscat
.shadowsocks
case let .quic(hostname, token):
.quic(hostname: hostname, token: token)
+ case .lwo:
+ .lwo
}
// If obfuscation is disabled, return endpoint as-is