summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2026-02-27 16:25:03 +0100
committerJon Petersson <jon.petersson@mullvad.net>2026-02-27 16:25:03 +0100
commit6676acf301c498a8dcb71a35f409b89968b3a750 (patch)
treed59d5398b6a3d03f2e2b04498e02d2546610ea3f /ios/MullvadVPN
parentfe84f9bad91e222ed768ede172fce1e2ada29bea (diff)
downloadmullvadvpn-debug-view.tar.xz
mullvadvpn-debug-view.zip
Add debug viewdebug-view
Diffstat (limited to 'ios/MullvadVPN')
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift1
-rw-r--r--ios/MullvadVPN/Classes/AppRoutes.swift16
-rw-r--r--ios/MullvadVPN/Containers/Root/HeaderBarView.swift4
-rw-r--r--ios/MullvadVPN/Containers/Root/RootContainerViewController.swift28
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift33
-rw-r--r--ios/MullvadVPN/Debug/DebugCoordinator.swift45
-rw-r--r--ios/MullvadVPN/Debug/DebugView.swift109
-rw-r--r--ios/MullvadVPN/Debug/DebugViewModel.swift301
-rw-r--r--ios/MullvadVPN/Extensions/String+Localization.swift13
9 files changed, 545 insertions, 5 deletions
diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
index 032025e15a..0bfbccaab9 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -185,6 +185,7 @@ public enum AccessibilityIdentifier: Equatable {
case notificationPromptSkipButton
case notificationPromptEnableButton
case includeAllNetworksView
+ case debugView
// Other UI elements
case accessMethodEnableSwitch
diff --git a/ios/MullvadVPN/Classes/AppRoutes.swift b/ios/MullvadVPN/Classes/AppRoutes.swift
index fbccb77554..906dec7993 100644
--- a/ios/MullvadVPN/Classes/AppRoutes.swift
+++ b/ios/MullvadVPN/Classes/AppRoutes.swift
@@ -43,12 +43,17 @@ enum AppRouteGroup: AppRouteGroupProtocol {
*/
case alert(_ alertId: String)
+ /**
+ Debug group.
+ */
+ case debug
+
var isModal: Bool {
switch self {
case .primary:
return false
- case .selectLocation, .account, .settings, .changelog, .alert:
+ case .selectLocation, .account, .settings, .changelog, .alert, .debug:
return true
}
}
@@ -57,7 +62,7 @@ enum AppRouteGroup: AppRouteGroupProtocol {
switch self {
case .primary:
return 0
- case .account, .selectLocation, .changelog:
+ case .account, .selectLocation, .changelog, .debug:
return 1
case .settings:
return 2
@@ -133,6 +138,11 @@ enum AppRoute: AppRouteProtocol {
*/
case tos, login, main, revoked, outOfTime, welcome
+ /**
+ Debug view route.
+ */
+ case debug
+
var isExclusive: Bool {
switch self {
case .account, .settings, .alert:
@@ -162,6 +172,8 @@ enum AppRoute: AppRouteProtocol {
return .settings
case let .alert(id):
return .alert(id)
+ case .debug:
+ return .debug
}
}
}
diff --git a/ios/MullvadVPN/Containers/Root/HeaderBarView.swift b/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
index adae341e5d..e7e08b066c 100644
--- a/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
+++ b/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
@@ -12,8 +12,6 @@ class HeaderBarView: UIView {
private let brandNameImage = UIImage(named: "LogoText")?
.withTintColor(UIColor.HeaderBar.brandNameColor, renderingMode: .alwaysOriginal)
- private let logoImageView = UIImageView(image: UIImage(named: "LogoIcon"))
-
private lazy var brandNameImageView: UIImageView = {
let imageView = UIImageView(image: brandNameImage)
imageView.contentMode = .scaleAspectFill
@@ -61,6 +59,8 @@ class HeaderBarView: UIView {
return layer
}()
+ let logoImageView = UIImageView(image: UIImage(named: "LogoIcon"))
+
let accountButton: UIButton = {
let button = makeHeaderBarButton(with: UIImage.Buttons.account)
button.setAccessibilityIdentifier(.accountButton)
diff --git a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
index e735264a29..680e08bbaf 100644
--- a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
+++ b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
@@ -62,6 +62,11 @@ extension RootContainment {
}
protocol RootContainerViewControllerDelegate: AnyObject, Sendable {
+ func rootContainerViewControllerShouldShowDebugView(
+ _ controller: RootContainerViewController,
+ animated: Bool
+ )
+
func rootContainerViewControllerShouldShowAccount(
_ controller: RootContainerViewController,
animated: Bool
@@ -293,6 +298,14 @@ class RootContainerViewController: UIViewController {
updateHeaderBarHiddenFromChildPreferences(animated: UIView.areAnimationsEnabled)
}
+ /// Request to display debug view
+ func showDebugView(animated: Bool) {
+ delegate?.rootContainerViewControllerShouldShowDebugView(
+ self,
+ animated: animated
+ )
+ }
+
/// Request to display settings controller
func showAccount(animated: Bool) {
delegate?.rootContainerViewControllerShouldShowAccount(
@@ -361,6 +374,15 @@ class RootContainerViewController: UIViewController {
// Prevent automatic layout margins adjustment as we manually control them.
headerBarView.insetsLayoutMarginsFromSafeArea = false
+ #if DEBUG
+ headerBarView.logoImageView.isUserInteractionEnabled = true
+ headerBarView.logoImageView.addGestureRecognizer(
+ UITapGestureRecognizer(
+ target: self,
+ action: #selector(handleLogoTap(_:)))
+ )
+ #endif
+
headerBarView.accountButton.addTarget(
self,
action: #selector(handleAccountButtonTap),
@@ -418,6 +440,12 @@ class RootContainerViewController: UIViewController {
return button
}
+ @objc private func handleLogoTap(_ gestureRecognizer: UIGestureRecognizer) {
+ if gestureRecognizer.state == .ended {
+ showDebugView(animated: true)
+ }
+ }
+
@objc private func handleAccountButtonTap() {
showAccount(animated: true)
}
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index 80dbb41d5f..88fcbdfd40 100644
--- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
@@ -10,6 +10,7 @@ import Combine
import MullvadREST
import MullvadSettings
import MullvadTypes
+import Network
import Routing
import UIKit
@@ -173,6 +174,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
presentDNSSettings(animated: animated, completion: completion)
case .ipOverrides:
presentIPOverride(animated: animated, completion: completion)
+ case .debug:
+ presentDebug(animated: animated, completion: completion)
}
}
@@ -189,7 +192,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
completion()
context.dismissedRoutes.forEach { $0.coordinator.removeFromParent() }
- case .selectLocation, .account, .settings, .changelog, .alert:
+ case .selectLocation, .account, .settings, .changelog, .alert, .debug:
guard let coordinator = dismissedRoute.coordinator as? Presentable else {
completion()
return assertionFailure("Expected presentable coordinator for \(dismissedRoute.route)")
@@ -819,7 +822,28 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
animated: true,
configuration: ModalPresentationConfiguration(modalPresentationStyle: .automatic)
)
+ }
+
+ private func presentDebug(animated: Bool, completion: @escaping @Sendable (Coordinator) -> Void) {
+ let viewModel = DebugViewModelImpl(
+ tunnelManager: tunnelManager,
+ nwPathMonitor: NWPathMonitor(),
+ appPreferences: appPreferences
+ )
+ let coordinator = DebugCoordinator(
+ navigationController: CustomNavigationController(),
+ viewModel: viewModel
+ )
+
+ coordinator.didFinish = { [weak self] _ in
+ self?.router.dismiss(.debug, animated: true)
+ }
+ coordinator.start(animated: animated)
+
+ presentChild(coordinator, animated: animated) {
+ completion(coordinator)
+ }
}
private func addTunnelObserver() {
@@ -1082,6 +1106,13 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
// MARK: - RootContainerViewControllerDelegate
+ func rootContainerViewControllerShouldShowDebugView(
+ _ controller: RootContainerViewController,
+ animated: Bool
+ ) {
+ router.present(.debug, animated: animated)
+ }
+
func rootContainerViewControllerShouldShowAccount(
_ controller: RootContainerViewController,
animated: Bool
diff --git a/ios/MullvadVPN/Debug/DebugCoordinator.swift b/ios/MullvadVPN/Debug/DebugCoordinator.swift
new file mode 100644
index 0000000000..30299b3fc2
--- /dev/null
+++ b/ios/MullvadVPN/Debug/DebugCoordinator.swift
@@ -0,0 +1,45 @@
+//
+// DebugCoordinator.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import Routing
+import SwiftUI
+
+class DebugCoordinator: Coordinator, Presentable, Presenting {
+ private let navigationController: UINavigationController
+ private let viewModel: DebugViewModelImpl
+ // private let alertPresenter: AlertPresenter
+
+ var presentedViewController: UIViewController {
+ navigationController
+ }
+
+ var didFinish: ((DebugCoordinator) -> Void)?
+
+ init(
+ navigationController: UINavigationController,
+ viewModel: DebugViewModelImpl
+ ) {
+ self.navigationController = navigationController
+ self.viewModel = viewModel
+
+ super.init()
+
+ // alertPresenter = AlertPresenter(context: self)
+ }
+
+ func start(animated: Bool) {
+ let view = DebugView(viewModel: viewModel)
+
+ let host = UIHostingController(rootView: view)
+ host.title = NSLocalizedString("Debug", comment: "")
+ host.view.setAccessibilityIdentifier(.debugView)
+ host.view.backgroundColor = .secondaryColor
+
+ navigationController.pushViewController(host, animated: animated)
+ }
+}
diff --git a/ios/MullvadVPN/Debug/DebugView.swift b/ios/MullvadVPN/Debug/DebugView.swift
new file mode 100644
index 0000000000..8de0299ca8
--- /dev/null
+++ b/ios/MullvadVPN/Debug/DebugView.swift
@@ -0,0 +1,109 @@
+//
+// DebugView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import SwiftUI
+
+@MainActor
+struct DebugView<ViewModel: DebugViewModel>: View {
+ @ObservedObject var viewModel: ViewModel
+ @State private var alert: MullvadAlert?
+
+ @State private var showRelays: Bool = false
+ @State private var showSettings: Bool = false
+
+ var body: some View {
+ ZStack {
+ ScrollView {
+ VStack(alignment: .leading) {
+ HStack {
+ Spacer()
+ }
+
+ connection()
+ settings()
+ }
+ .background(Color.mullvadBackground)
+ Spacer()
+ }
+ .padding(EdgeInsets(UIMetrics.contentLayoutMargins))
+ }
+ .mullvadAlert(item: $alert)
+ .font(.mullvadTiny)
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .background(Color.mullvadBackground)
+
+ Spacer()
+ }
+}
+
+extension DebugView {
+ private func connection() -> some View {
+ VStack {
+ Button {
+ withAnimation {
+ showRelays.toggle()
+ }
+ } label: {
+ Text("Connection".excludeLocalization)
+ .font(.mullvadMedium)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ Spacer()
+ }
+
+ RowSeparator()
+
+ VStack(alignment: .leading) {
+ ForEach(Array(viewModel.connection), id: \.title) { item in
+ createSection(title: item.title, rows: item.data)
+ }
+ }
+ .showIf(showRelays)
+ }
+ }
+
+ private func settings() -> some View {
+ VStack {
+ Button {
+ withAnimation {
+ showSettings.toggle()
+ }
+ } label: {
+ Text("Settings".excludeLocalization)
+ .font(.mullvadMedium)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ Spacer()
+ }
+
+ RowSeparator()
+
+ VStack(alignment: .leading) {
+ ForEach(Array(viewModel.settings), id: \.title) { item in
+ createSection(title: item.title, rows: item.data)
+ }
+ }.showIf(showSettings)
+ }
+ }
+
+ private func createSection(title: String, rows: [String]) -> some View {
+ VStack(alignment: .leading) {
+ Text(title)
+ .font(.mullvadSmallSemiBold)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ ForEach(rows, id: \.self) {
+ Text($0)
+ }
+
+ RowSeparator()
+ }
+ }
+}
+
+#Preview {
+ DebugView(viewModel: MockDebugViewModel())
+}
diff --git a/ios/MullvadVPN/Debug/DebugViewModel.swift b/ios/MullvadVPN/Debug/DebugViewModel.swift
new file mode 100644
index 0000000000..e6f4ebd1db
--- /dev/null
+++ b/ios/MullvadVPN/Debug/DebugViewModel.swift
@@ -0,0 +1,301 @@
+//
+// DebugViewModel.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import Network
+import PacketTunnelCore
+import SwiftUI
+
+@MainActor
+protocol DebugViewModel: ObservableObject {
+ typealias Item = (title: String, data: [String])
+
+ var tunnelSettings: LatestTunnelSettings { get }
+ var nwPathStatus: NWPath.Status { get }
+
+ var connection: [Item] { get }
+ var settings: [Item] { get }
+}
+
+class DebugViewModelImpl: DebugViewModel {
+ var tunnelManager: TunnelManager
+ var nwPathMonitor: NWPathMonitor
+ var appPreferences: AppPreferencesDataSource
+ var tunnelObserver: TunnelBlockObserver!
+
+ var tunnelSettings: LatestTunnelSettings
+ var tunnelStatus: TunnelStatus
+ var nwPathStatus = NWPath.Status.unsatisfied
+
+ @Published var connection = [Item]()
+ @Published var settings = [Item]()
+
+ init(
+ tunnelManager: TunnelManager,
+ nwPathMonitor: NWPathMonitor,
+ appPreferences: AppPreferencesDataSource
+ ) {
+ self.tunnelManager = tunnelManager
+ self.nwPathMonitor = nwPathMonitor
+ self.appPreferences = appPreferences
+
+ tunnelSettings = tunnelManager.settings
+ tunnelStatus = tunnelManager.tunnelStatus
+
+ refreshData()
+
+ tunnelObserver = TunnelBlockObserver(
+ didUpdateTunnelStatus: { _, status in
+ self.tunnelStatus = status
+ self.refreshData()
+ },
+ didUpdateDeviceState: { _, _, _ in },
+ didUpdateTunnelSettings: { _, settings in
+ self.tunnelSettings = settings
+ self.refreshData()
+ }
+ )
+ self.tunnelManager.addObserver(tunnelObserver)
+ }
+
+ // Arrange these functions to get the desired section order in the view.
+ private func refreshData() {
+ // Connection
+ setRelays()
+ setObfuscation()
+
+ // Settings
+ setRelaySettings()
+ setMultihopSettings()
+ setDaitaSettings()
+ setObfuscationSettings()
+ setQuantumResistanceSettings()
+ setIncludeAllNetworksSettings()
+ setMullvadDnsBlockers()
+ setCustomDnsBlockers()
+ }
+
+ private func update(item: Item, in list: inout [Item]) {
+ guard let index = (list.firstIndex { $0.title == item.title }) else {
+ list.append(item)
+ return
+ }
+
+ list.remove(at: index)
+ list.insert(item, at: index)
+ }
+}
+
+// MARK: Connection
+
+extension DebugViewModelImpl {
+ private func setRelays() {
+ let entry = tunnelStatus.state.relays?.entry?.debugDescription ?? "-"
+ let exit = tunnelStatus.state.relays?.exit.debugDescription ?? "-"
+
+ update(
+ item: (
+ title: "Relays",
+ data: [
+ "Entry: \(entry)",
+ "Exit: \(exit)",
+ ]
+ ), in: &connection
+ )
+ }
+
+ private func setObfuscation() {
+ update(
+ item: (
+ title: "Obfuscation",
+ data: [
+ tunnelStatus.observedState.connectionState?.obfuscationMethod.description ?? "-"
+ ]
+ ), in: &connection
+ )
+ }
+}
+
+// MARK: Settings
+
+extension DebugViewModelImpl {
+ func setRelaySettings() {
+ let entry = tunnelSettings.relayConstraints.entryLocations.value?.locations.first?.stringRepresentation ?? "-"
+ let exit = tunnelSettings.relayConstraints.exitLocations.value?.locations.first?.stringRepresentation ?? "-"
+
+ update(
+ item: (
+ title: "Relays",
+ data: [
+ "Entry: \(entry)",
+ "Exit: \(exit)",
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setMullvadDnsBlockers() {
+ let blockingOptions = tunnelSettings.dnsSettings.blockingOptions
+ var dnsBlockers = [String]()
+
+ if blockingOptions.contains(.blockAdvertising) {
+ dnsBlockers.append("Advertising (\(DNSBlockingOptions.blockAdvertising.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockTracking) {
+ dnsBlockers.append("Tracking (\(DNSBlockingOptions.blockTracking.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockMalware) {
+ dnsBlockers.append("Malware (\(DNSBlockingOptions.blockMalware.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockAdultContent) {
+ dnsBlockers.append("Adult content (\(DNSBlockingOptions.blockAdultContent.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockGambling) {
+ dnsBlockers.append("Gambling (\(DNSBlockingOptions.blockGambling.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockSocialMedia) {
+ dnsBlockers.append("Social media (\(DNSBlockingOptions.blockSocialMedia.serverAddress!))")
+ }
+
+ update(
+ item: (
+ title: "Mullvad DNS blockers",
+ data: dnsBlockers.isEmpty ? ["-"] : dnsBlockers
+ ), in: &settings
+ )
+ }
+
+ func setCustomDnsBlockers() {
+ let customAddresses = tunnelSettings.dnsSettings.customDNSDomains.map { $0.debugDescription }
+
+ update(
+ item: (
+ title: "Custom DNS blockers",
+ data: customAddresses.isEmpty ? ["-"] : customAddresses
+ ), in: &settings
+ )
+ }
+
+ func setObfuscationSettings() {
+ let method = tunnelSettings.wireGuardObfuscation.state.description
+ let udpTcpPort = tunnelSettings.wireGuardObfuscation.udpOverTcpPort.description
+ let shadowSocksPort = tunnelSettings.wireGuardObfuscation.shadowsocksPort.description
+
+ update(
+ item: (
+ title: "Obfuscation",
+ data: [
+ "Method: \(method)",
+ "UDP over TCP port: \(udpTcpPort)",
+ "Shadowsocks port: \(shadowSocksPort)",
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setQuantumResistanceSettings() {
+ update(
+ item: (
+ title: "Quantum resistance",
+ data: [
+ tunnelSettings.tunnelQuantumResistance.isEnabled ? "Enabled" : "Disabled"
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setMultihopSettings() {
+ update(
+ item: (
+ title: "Multihop",
+ data: [
+ tunnelSettings.tunnelMultihopState.isEnabled ? "Enabled" : "Disabled"
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setDaitaSettings() {
+ let daitaIsEnabled = tunnelSettings.daita.daitaState.isEnabled ? "Enabled" : "Disabled"
+ let directOnlyIsEnabled = tunnelSettings.daita.directOnlyState.isEnabled ? "Enabled" : "Disabled"
+
+ update(
+ item: (
+ title: "DAITA",
+ data: [
+ "DAITA: \(daitaIsEnabled)",
+ "Direct only: \(directOnlyIsEnabled)",
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setIncludeAllNetworksSettings() {
+ let includeAllNetworksIsEnabled =
+ tunnelSettings.includeAllNetworks.includeAllNetworksIsEnabled ? "Enabled" : "Disabled"
+ let localNetworkSharingIsEnabled =
+ tunnelSettings.includeAllNetworks.localNetworkSharingIsEnabled ? "Enabled" : "Disabled"
+ let consent = appPreferences.includeAllNetworksConsent ? "True" : "False"
+
+ update(
+ item: (
+ title: "Force all apps",
+ data: [
+ "Force all apps: \(includeAllNetworksIsEnabled)",
+ "Local network sharing: \(localNetworkSharingIsEnabled)",
+ "Consent: \(consent)",
+ ]
+ ), in: &settings
+ )
+ }
+}
+
+// MARK: Mock
+
+class MockDebugViewModel: DebugViewModel {
+ var tunnelSettings: LatestTunnelSettings = LatestTunnelSettings()
+ var nwPathStatus: NWPath.Status = NWPath.Status.unsatisfied
+
+ // Connection
+ var connection = [
+ (
+ title: "Relays",
+ data: [
+ "Entry: Sweden, Gothenburg, se-got-001",
+ "Exit: Sweden, Gothenburg, se-got-002",
+ ]
+ ),
+ (title: "Obfuscation", data: [""]),
+ ]
+
+ // Settings
+ var settings = [
+ (title: "Relays", data: ["Entry: se-se-got-se-got-001", "Exit: se-se-got-se-got-001"]),
+ (
+ title: "Mullvad DNS blockers",
+ data: [
+ "Advertising (100.64.0.1)",
+ "Tracking (100.64.0.2)",
+ "Social Media (100.64.0.3)",
+ ]
+ ),
+ (title: "Custom DNS blockers", data: ["192.168.1.1", "192.168.1.2"]),
+ (title: "Obfuscation", data: ["QUIC", "UDP over TCP port: 53", "Shadowsocks port: 53"]),
+ (title: "Quantum resistance", data: ["Enabled"]),
+ (title: "Multihop", data: ["Disabled"]),
+ (title: "DAITA", data: ["DAITA: Enabled", "Direct only: Disabled"]),
+ (
+ title: "Force all apps",
+ data: [
+ "Force all apps: Disabled",
+ "Local network sharing: Disabled",
+ "Consent: False",
+ ]
+ ),
+ ]
+}
diff --git a/ios/MullvadVPN/Extensions/String+Localization.swift b/ios/MullvadVPN/Extensions/String+Localization.swift
new file mode 100644
index 0000000000..766f61a146
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/String+Localization.swift
@@ -0,0 +1,13 @@
+//
+// String+Localization.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+extension String {
+ var excludeLocalization: String {
+ String(self)
+ }
+}