diff options
| author | Jon Petersson <jon.petersson@mullvad.net> | 2026-02-27 16:25:03 +0100 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@mullvad.net> | 2026-02-27 16:25:03 +0100 |
| commit | 6676acf301c498a8dcb71a35f409b89968b3a750 (patch) | |
| tree | d59d5398b6a3d03f2e2b04498e02d2546610ea3f /ios/MullvadVPN | |
| parent | fe84f9bad91e222ed768ede172fce1e2ada29bea (diff) | |
| download | mullvadvpn-debug-view.tar.xz mullvadvpn-debug-view.zip | |
Add debug viewdebug-view
Diffstat (limited to 'ios/MullvadVPN')
| -rw-r--r-- | ios/MullvadVPN/Classes/AccessbilityIdentifier.swift | 1 | ||||
| -rw-r--r-- | ios/MullvadVPN/Classes/AppRoutes.swift | 16 | ||||
| -rw-r--r-- | ios/MullvadVPN/Containers/Root/HeaderBarView.swift | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/Containers/Root/RootContainerViewController.swift | 28 | ||||
| -rw-r--r-- | ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift | 33 | ||||
| -rw-r--r-- | ios/MullvadVPN/Debug/DebugCoordinator.swift | 45 | ||||
| -rw-r--r-- | ios/MullvadVPN/Debug/DebugView.swift | 109 | ||||
| -rw-r--r-- | ios/MullvadVPN/Debug/DebugViewModel.swift | 301 | ||||
| -rw-r--r-- | ios/MullvadVPN/Extensions/String+Localization.swift | 13 |
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) + } +} |
