diff options
| -rw-r--r-- | ios/Assets/Localizable.xcstrings | 4 | ||||
| -rw-r--r-- | ios/MullvadRustRuntime/include/mullvad_rust_runtime.h | 28 | ||||
| -rw-r--r-- | ios/MullvadSettings/WireGuardObfuscationSettings.swift | 19 | ||||
| -rw-r--r-- | ios/MullvadTypes/ObfuscationMethod.swift | 15 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 24 | ||||
| -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 |
14 files changed, 605 insertions, 35 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings index 277e38b01e..79bdec6c50 100644 --- a/ios/Assets/Localizable.xcstrings +++ b/ios/Assets/Localizable.xcstrings @@ -15054,6 +15054,10 @@ } } }, + "Debug" : { + "comment" : "Title of the debug view.", + "isCommentAutoGenerated" : true + }, "Debug options" : { "localizations" : { "da" : { diff --git a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h index 69e2a58f34..e35f049f27 100644 --- a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h +++ b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h @@ -746,34 +746,6 @@ struct SwiftShadowsocksLoaderWrapper init_swift_shadowsocks_loader_wrapper(const * object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish` * when completion finishes (in completion.finish). * - * `retry_strategy` must have been created by a call to either of the following functions - * `mullvad_api_retry_strategy_never`, `mullvad_api_retry_strategy_constant` or `mullvad_api_retry_strategy_exponential` - * - * `account_number` must be a pointer to a null terminated string. - * - * `body` must be a pointer to a contiguous memory segment - * - * `body_size` must be the size of the body - * - * This function is not safe to call multiple times with the same `CompletionCookie`. - */ -struct SwiftCancelHandle mullvad_ios_legacy_storekit_payment(struct SwiftApiContext api_context, - void *completion_cookie, - struct SwiftRetryStrategy retry_strategy, - const char *account_number, - const uint8_t *body, - uintptr_t body_size); - -/** - * # Safety - * - * `api_context` must be pointing to a valid instance of `SwiftApiContext`. A `SwiftApiContext` is created - * by calling `mullvad_api_init_new`. - * - * This function takes ownership of `completion_cookie`, which must be pointing to a valid instance of Swift - * object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish` - * when completion finishes (in completion.finish). - * * `account_number` must be a pointer to a null terminated string. * * `retry_strategy` must have been created by a call to either of the following functions diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift index abc323ff92..6e561a7f72 100644 --- a/ios/MullvadSettings/WireGuardObfuscationSettings.swift +++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift @@ -12,7 +12,7 @@ import MullvadTypes /// Whether obfuscation is enabled and which method is used. /// /// `.automatic` means an algorithm will decide whether to use obfuscation or not. -public enum WireGuardObfuscationState: Codable, Sendable { +public enum WireGuardObfuscationState: CustomStringConvertible, Codable, Sendable { @available(*, deprecated, renamed: "udpOverTcp") case on @@ -54,6 +54,23 @@ public enum WireGuardObfuscationState: Codable, Sendable { public var isEnabled: Bool { [.udpOverTcp, .shadowsocks, .quic].contains(self) } + + public var description: String { + switch self { + case .automatic: + "Automatic" + case .on: + "On" + case .udpOverTcp: + "UDP over TCP" + case .shadowsocks: + "Shadowsocks" + case .quic: + "QUIC" + case .off: + "Off" + } + } } public enum WireGuardObfuscationUdpOverTcpPort: Codable, Equatable, CustomStringConvertible, Sendable { diff --git a/ios/MullvadTypes/ObfuscationMethod.swift b/ios/MullvadTypes/ObfuscationMethod.swift index 87d02cf78e..d577de336d 100644 --- a/ios/MullvadTypes/ObfuscationMethod.swift +++ b/ios/MullvadTypes/ObfuscationMethod.swift @@ -9,7 +9,7 @@ import Foundation /// Describes the resolved obfuscation method with all required parameters. -public enum ObfuscationMethod: Equatable, Codable, Sendable { +public enum ObfuscationMethod: CustomStringConvertible, Equatable, Codable, Sendable { case off case udpOverTcp case shadowsocks @@ -23,4 +23,17 @@ public enum ObfuscationMethod: Equatable, Codable, Sendable { true } } + + public var description: String { + switch self { + case .off: + "Off" + case .udpOverTcp: + "UDP over TCP" + case .shadowsocks: + "Shadowsocks" + case .quic: + "QUIC" + } + } } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index f9c6214556..f7ca82750d 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -478,6 +478,10 @@ 7A3AD5012C1068A800E9AD90 /* RelayPicking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3AD5002C1068A800E9AD90 /* RelayPicking.swift */; }; 7A3FD1B52AD4465A0042BEA6 /* AppMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */; }; 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; }; + 7A42EBF42F518BE2001B8B40 /* DebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF32F518BD0001B8B40 /* DebugViewModel.swift */; }; + 7A42EBF62F518DE1001B8B40 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF52F518DDC001B8B40 /* DebugView.swift */; }; + 7A42EBF82F51A0E3001B8B40 /* DebugCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF72F51A0DB001B8B40 /* DebugCoordinator.swift */; }; + 7A42EBFA2F51EC94001B8B40 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF92F51EC8A001B8B40 /* String+Localization.swift */; }; 7A45CFC62C05FF6A00D80B21 /* ScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A45CFC22C05FF2F00D80B21 /* ScreenshotTests.swift */; }; 7A45CFC72C071DD400D80B21 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0C79D23F1CEBA00FE9BA7 /* SnapshotHelper.swift */; }; 7A460F9D2F34E2FC005A265D /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A460F9C2F34E2FC005A265D /* Bundle+ProductVersion.swift */; }; @@ -2060,6 +2064,10 @@ 7A3AD5002C1068A800E9AD90 /* RelayPicking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicking.swift; sourceTree = "<group>"; }; 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = "<group>"; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = "<group>"; }; + 7A42EBF32F518BD0001B8B40 /* DebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewModel.swift; sourceTree = "<group>"; }; + 7A42EBF52F518DDC001B8B40 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = "<group>"; }; + 7A42EBF72F51A0DB001B8B40 /* DebugCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugCoordinator.swift; sourceTree = "<group>"; }; + 7A42EBF92F51EC8A001B8B40 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = "<group>"; }; 7A45CFC22C05FF2F00D80B21 /* ScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTests.swift; sourceTree = "<group>"; }; 7A460F9C2F34E2FC005A265D /* Bundle+ProductVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+ProductVersion.swift"; sourceTree = "<group>"; }; 7A460F9E2F35D04C005A265D /* NewAppVersionInAppNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAppVersionInAppNotificationProvider.swift; sourceTree = "<group>"; }; @@ -3463,6 +3471,7 @@ 7AA564472F2B680D001D1FB9 /* String+Assets.swift */, 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */, 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */, + 7A42EBF92F51EC8A001B8B40 /* String+Localization.swift */, 58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */, 5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */, 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */, @@ -3996,6 +4005,7 @@ 583FE02829C1B079006E85F9 /* Classes */, 58C774C929AB543C003A1A56 /* Containers */, 58CAF9F22983D32200BE19F7 /* Coordinators */, + 7A42EBF22F518BC4001B8B40 /* Debug */, 583FE02329C1AC9F006E85F9 /* Extensions */, F09D04B82AE94F27003D4F89 /* GeneralAPIs */, 58B26E1F2943516500D5980C /* Notifications */, @@ -4307,6 +4317,16 @@ path = APIRequest; sourceTree = "<group>"; }; + 7A42EBF22F518BC4001B8B40 /* Debug */ = { + isa = PBXGroup; + children = ( + 7A42EBF72F51A0DB001B8B40 /* DebugCoordinator.swift */, + 7A42EBF52F518DDC001B8B40 /* DebugView.swift */, + 7A42EBF32F518BD0001B8B40 /* DebugViewModel.swift */, + ); + path = Debug; + sourceTree = "<group>"; + }; 7A45CFCD2C08697100D80B21 /* Screenshots */ = { isa = PBXGroup; children = ( @@ -6398,6 +6418,7 @@ 586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */, 7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */, F062000A2CB7EB42002E6DB9 /* CGSize+Helpers.swift in Sources */, + 7A42EBF82F51A0E3001B8B40 /* DebugCoordinator.swift in Sources */, 586C0D892B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift in Sources */, 58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */, 584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */, @@ -6455,6 +6476,7 @@ 58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, 7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */, + 7A42EBF62F518DE1001B8B40 /* DebugView.swift in Sources */, F09C97232D3122F300ADE747 /* ChangeLogReader.swift in Sources */, 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */, 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, @@ -6564,6 +6586,7 @@ F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */, F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */, 7A6000F62B60092F001CF0D9 /* AccessMethodViewModelEditing.swift in Sources */, + 7A42EBFA2F51EC94001B8B40 /* String+Localization.swift in Sources */, 58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */, 5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */, 5827B0AE2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift in Sources */, @@ -6719,6 +6742,7 @@ 44E1F7582D3EA83A003A60FF /* DestinationDescriber.swift in Sources */, 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */, + 7A42EBF42F518BE2001B8B40 /* DebugViewModel.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */, 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, 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) + } +} |
