diff options
Diffstat (limited to 'Sources/WireGuardApp/UI/iOS')
50 files changed, 3770 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/iOS/AppDelegate.swift b/Sources/WireGuardApp/UI/iOS/AppDelegate.swift new file mode 100644 index 0000000..fbb09c7 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/AppDelegate.swift @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + var mainVC: MainViewController? + var isLaunchedForSpecificAction = false + + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path) + + if let launchOptions = launchOptions { + if launchOptions[.url] != nil || launchOptions[.shortcutItem] != nil { + isLaunchedForSpecificAction = true + } + } + + let window = UIWindow(frame: UIScreen.main.bounds) + self.window = window + + let mainVC = MainViewController() + window.rootViewController = mainVC + window.makeKeyAndVisible() + + self.mainVC = mainVC + + return true + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + mainVC?.importFromDisposableFile(url: url) + return true + } + + func applicationDidBecomeActive(_ application: UIApplication) { + mainVC?.refreshTunnelConnectionStatuses() + } + + func applicationWillResignActive(_ application: UIApplication) { + guard let allTunnelNames = mainVC?.allTunnelNames() else { return } + application.shortcutItems = QuickActionItem.createItems(allTunnelNames: allTunnelNames) + } + + func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + guard shortcutItem.type == QuickActionItem.type else { + completionHandler(false) + return + } + let tunnelName = shortcutItem.localizedTitle + mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: true) + completionHandler(true) + } +} + +extension AppDelegate { + func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool { + return true + } + + func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { + return !self.isLaunchedForSpecificAction + } + + func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { + guard let vcIdentifier = identifierComponents.last else { return nil } + if vcIdentifier.hasPrefix("TunnelDetailVC:") { + let tunnelName = String(vcIdentifier.suffix(vcIdentifier.count - "TunnelDetailVC:".count)) + if let tunnelsManager = mainVC?.tunnelsManager { + if let tunnel = tunnelsManager.tunnel(named: tunnelName) { + return TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) + } + } else { + // Show it when tunnelsManager is available + mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: false) + } + } + return nil + } +} diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9f4dceb --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "wireguard_logo_20pt@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "wireguard_logo_20pt@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "wireguard_logo_29pt@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "wireguard_logo_29pt@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "wireguard_logo_40pt@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "wireguard_logo_40pt@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "wireguard_logo_60pt@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "wireguard_logo_60pt@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "wireguard_logo_20pt@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "wireguard_logo_20pt@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "wireguard_logo_29pt@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "wireguard_logo_29pt@2x-1.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "wireguard_logo_40pt@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "wireguard_logo_40pt@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "wireguard_logo_76pt@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "wireguard_logo_76pt@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "wireguard_logo_83.5pt@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "wireguard_logo.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png Binary files differnew file mode 100644 index 0000000..ec74f4d --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png Binary files differnew file mode 100644 index 0000000..e0dc54d --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png Binary files differnew file mode 100644 index 0000000..429297a --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png Binary files differnew file mode 100644 index 0000000..429297a --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png Binary files differnew file mode 100644 index 0000000..eb79183 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png Binary files differnew file mode 100644 index 0000000..a8fd5c2 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png Binary files differnew file mode 100644 index 0000000..3645f0e --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png Binary files differnew file mode 100644 index 0000000..3645f0e --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png Binary files differnew file mode 100644 index 0000000..386bb88 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png Binary files differnew file mode 100644 index 0000000..429297a --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png Binary files differnew file mode 100644 index 0000000..49bfff8 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png Binary files differnew file mode 100644 index 0000000..49bfff8 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png Binary files differnew file mode 100644 index 0000000..8aa7b6c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png Binary files differnew file mode 100644 index 0000000..8aa7b6c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png Binary files differnew file mode 100644 index 0000000..f6c3318 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png Binary files differnew file mode 100644 index 0000000..b0e2c3c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png Binary files differnew file mode 100644 index 0000000..0c708bc --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png Binary files differnew file mode 100644 index 0000000..b7338c4 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json new file mode 100644 index 0000000..6c935c1 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "wireguard.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf Binary files differnew file mode 100644 index 0000000..69469c5 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf diff --git a/Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard b/Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..a88aa5b --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="dyO-pm-zxZ"> + <device id="ipad9_7" orientation="landscape"> + <adaptation id="fullscreen"/> + </device> + <dependencies> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--View Controller--> + <scene sceneID="wbj-wA-LRS"> + <objects> + <viewController id="xPs-rU-RaC" sceneMemberID="viewController"> + <view key="view" contentMode="scaleToFill" id="VmF-QJ-FIU"> + <rect key="frame" x="0.0" y="0.0" width="703.5" height="768"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <viewLayoutGuide key="safeArea" id="gU2-7a-hJw"/> + </view> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="XJD-RE-ATd" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + <!--Table View Controller--> + <scene sceneID="pXL-Um-fWR"> + <objects> + <tableViewController clearsSelectionOnViewWillAppear="NO" id="9s0-Gz-xEQ" sceneMemberID="viewController"> + <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="PWV-jc-Hj5"> + <rect key="frame" x="0.0" y="0.0" width="320" height="768"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <prototypes> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="LaunchScreenCellReuseId" id="bv7-0X-YhD"> + <rect key="frame" x="0.0" y="28" width="320" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="bv7-0X-YhD" id="Uag-Pa-rmu"> + <rect key="frame" x="0.0" y="0.0" width="320" height="44"/> + <autoresizingMask key="autoresizingMask"/> + </tableViewCellContentView> + </tableViewCell> + </prototypes> + <connections> + <outlet property="dataSource" destination="9s0-Gz-xEQ" id="djY-Us-7mp"/> + <outlet property="delegate" destination="9s0-Gz-xEQ" id="Frz-TZ-bD1"/> + </connections> + </tableView> + <navigationItem key="navigationItem" id="0ZE-eq-OPY"/> + </tableViewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="YJ1-t3-sLe" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + <!--Navigation Controller--> + <scene sceneID="bPm-bN-3Gh"> + <objects> + <navigationController id="Fi9-6j-pBd" sceneMemberID="viewController"> + <navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="h1o-PJ-Yrv"> + <rect key="frame" x="0.0" y="20" width="320" height="50"/> + <autoresizingMask key="autoresizingMask"/> + </navigationBar> + <connections> + <segue destination="9s0-Gz-xEQ" kind="relationship" relationship="rootViewController" id="Gdt-8A-Sqj"/> + </connections> + </navigationController> + <placeholder placeholderIdentifier="IBFirstResponder" id="fqW-5d-sYk" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + <!--Split View Controller--> + <scene sceneID="FTm-2q-Sdy"> + <objects> + <splitViewController id="dyO-pm-zxZ" sceneMemberID="viewController"> + <connections> + <segue destination="Fi9-6j-pBd" kind="relationship" relationship="masterViewController" id="mFt-pZ-wHb"/> + <segue destination="xPs-rU-RaC" kind="relationship" relationship="detailViewController" id="q2k-Zf-dXJ"/> + </connections> + </splitViewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="XEu-8J-cff" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + </scenes> +</document> diff --git a/Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift b/Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift new file mode 100644 index 0000000..add648b --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ConfirmationAlertPresenter { + static func showConfirmationAlert(message: String, buttonTitle: String, from sourceObject: AnyObject, presentingVC: UIViewController, onConfirmed: @escaping (() -> Void)) { + let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in + onConfirmed() + } + let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel) + let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) + alert.addAction(destroyAction) + alert.addAction(cancelAction) + + if let sourceView = sourceObject as? UIView { + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + } else if let sourceBarButtonItem = sourceObject as? UIBarButtonItem { + alert.popoverPresentationController?.barButtonItem = sourceBarButtonItem + } + + presentingVC.present(alert, animated: true, completion: nil) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift b/Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift new file mode 100644 index 0000000..6961ac1 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +class ErrorPresenter: ErrorPresenterProtocol { + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) { + guard let sourceVC = sourceVC as? UIViewController else { return } + + let okAction = UIAlertAction(title: "OK", style: .default) { _ in + onDismissal?() + } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(okAction) + + sourceVC.present(alert, animated: true, completion: onPresented) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/Info.plist b/Sources/WireGuardApp/UI/iOS/Info.plist new file mode 100644 index 0000000..7d91077 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Info.plist @@ -0,0 +1,131 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>conf</string> + </array> + <key>CFBundleTypeIconFiles</key> + <array> + <string>wireguard_doc_logo_22x29.png</string> + <string>wireguard_doc_logo_44x58.png</string> + <string>wireguard_doc_logo_64x64.png</string> + <string>wireguard_doc_logo_320x320.png</string> + </array> + <key>CFBundleTypeName</key> + <string>WireGuard wg-quick configuration file</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Default</string> + <key>LSItemContentTypes</key> + <array> + <string>com.wireguard.config.quick</string> + </array> + </dict> + <dict> + <key>CFBundleTypeName</key> + <string>Zip file</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>com.pkware.zip-archive</string> + </array> + </dict> + <dict> + <key>CFBundleTypeIconFiles</key> + <array/> + <key>CFBundleTypeName</key> + <string>Text file</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.text</string> + </array> + </dict> + </array> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleDisplayName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>$(VERSION_NAME)</string> + <key>CFBundleVersion</key> + <string>$(VERSION_ID)</string> + <key>LSRequiresIPhoneOS</key> + <true/> + <key>LSSupportsOpeningDocumentsInPlace</key> + <false/> + <key>NSCameraUsageDescription</key> + <string>Localized</string> + <key>UILaunchStoryboardName</key> + <string>LaunchScreen</string> + <key>UIRequiredDeviceCapabilities</key> + <array> + </array> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UISupportedInterfaceOrientations~ipad</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationPortraitUpsideDown</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UTExportedTypeDeclarations</key> + <array> + <dict> + <key>UTTypeConformsTo</key> + <array> + <string>public.text</string> + </array> + <key>UTTypeDescription</key> + <string>WireGuard wg-quick configuration file</string> + <key>UTTypeIconFiles</key> + <array> + <string>wireguard_doc_logo_22x29.png</string> + <string>wireguard_doc_logo_44x58.png</string> + <string>wireguard_doc_logo_64x64.png</string> + <string>wireguard_doc_logo_320x320.png</string> + </array> + <key>UTTypeIdentifier</key> + <string>com.wireguard.config.quick</string> + <key>UTTypeTagSpecification</key> + <dict> + <key>public.filename-extension</key> + <string>conf</string> + </dict> + </dict> + </array> + <key>NSFaceIDUsageDescription</key> + <string>Localized</string> + <key>com.wireguard.ios.app_group_id</key> + <string>group.$(APP_ID_IOS)</string> +</dict> +</plist> diff --git a/Sources/WireGuardApp/UI/iOS/QuickActionItem.swift b/Sources/WireGuardApp/UI/iOS/QuickActionItem.swift new file mode 100644 index 0000000..587e31f --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/QuickActionItem.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class QuickActionItem: UIApplicationShortcutItem { + static let type = "WireGuardTunnelActivateAndShow" + + init(tunnelName: String) { + super.init(type: QuickActionItem.type, localizedTitle: tunnelName, localizedSubtitle: nil, icon: nil, userInfo: nil) + } + + static func createItems(allTunnelNames: [String]) -> [QuickActionItem] { + let numberOfItems = 10 + // Currently, only 4 items shown by iOS, but that can increase in the future. + // iOS will discard additional items we give it. + var tunnelNames = RecentTunnelsTracker.recentlyActivatedTunnelNames(limit: numberOfItems) + let numberOfSlotsRemaining = numberOfItems - tunnelNames.count + if numberOfSlotsRemaining > 0 { + let moreTunnels = allTunnelNames.filter { !tunnelNames.contains($0) }.prefix(numberOfSlotsRemaining) + tunnelNames.append(contentsOf: moreTunnels) + } + return tunnelNames.map { QuickActionItem(tunnelName: $0) } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift b/Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift new file mode 100644 index 0000000..f9bf813 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import Foundation + +class RecentTunnelsTracker { + + private static let keyRecentlyActivatedTunnelNames = "recentlyActivatedTunnelNames" + private static let maxNumberOfTunnels = 10 + + private static var userDefaults: UserDefaults? { + guard let appGroupId = FileManager.appGroupId else { + wg_log(.error, staticMessage: "Cannot obtain app group ID from bundle for tracking recently used tunnels") + return nil + } + guard let userDefaults = UserDefaults(suiteName: appGroupId) else { + wg_log(.error, staticMessage: "Cannot obtain shared user defaults for tracking recently used tunnels") + return nil + } + return userDefaults + } + + static func handleTunnelActivated(tunnelName: String) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if let existingIndex = recentTunnels.firstIndex(of: tunnelName) { + recentTunnels.remove(at: existingIndex) + } + recentTunnels.insert(tunnelName, at: 0) + if recentTunnels.count > maxNumberOfTunnels { + recentTunnels.removeLast(recentTunnels.count - maxNumberOfTunnels) + } + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + + static func handleTunnelRemoved(tunnelName: String) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if let existingIndex = recentTunnels.firstIndex(of: tunnelName) { + recentTunnels.remove(at: existingIndex) + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + } + + static func handleTunnelRenamed(oldName: String, newName: String) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if let existingIndex = recentTunnels.firstIndex(of: oldName) { + recentTunnels[existingIndex] = newName + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + } + + static func cleanupTunnels(except tunnelNamesToKeep: Set<String>) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + let oldCount = recentTunnels.count + recentTunnels.removeAll { !tunnelNamesToKeep.contains($0) } + if oldCount != recentTunnels.count { + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + } + + static func recentlyActivatedTunnelNames(limit: Int) -> [String] { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return [] } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if limit < recentTunnels.count { + recentTunnels.removeLast(recentTunnels.count - limit) + } + return recentTunnels + } +} diff --git a/Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift b/Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift new file mode 100644 index 0000000..217a3ca --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +extension UITableViewCell { + static var reuseIdentifier: String { + return NSStringFromClass(self) + } +} + +extension UITableView { + func register<T: UITableViewCell>(_: T.Type) { + register(T.self, forCellReuseIdentifier: T.reuseIdentifier) + } + + func dequeueReusableCell<T: UITableViewCell>(for indexPath: IndexPath) -> T { + // swiftlint:disable:next force_cast + return dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as! T + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift b/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift new file mode 100644 index 0000000..6f9d55c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class BorderedTextButton: UIView { + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + override var intrinsicContentSize: CGSize { + let buttonSize = button.intrinsicContentSize + return CGSize(width: buttonSize.width + 32, height: buttonSize.height + 16) + } + + var title: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + + var onTapped: (() -> Void)? + + init() { + super.init(frame: CGRect.zero) + + layer.borderWidth = 1 + layer.cornerRadius = 5 + layer.borderColor = button.tintColor.cgColor + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: centerXAnchor), + button.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func buttonTapped() { + onTapped?() + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift b/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift new file mode 100644 index 0000000..ae7a144 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var hasDestructiveAction: Bool { + get { return button.tintColor == .systemRed } + set(value) { button.tintColor = value ? .systemRed : buttonStandardTintColor } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + var buttonStandardTintColor: UIColor + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + buttonStandardTintColor = button.tintColor + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + hasDestructiveAction = false + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift b/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift new file mode 100644 index 0000000..fedef53 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class CheckmarkCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + var isChecked: Bool { + didSet { + accessoryType = isChecked ? .checkmark : .none + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + isChecked = false + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + isChecked = false + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift b/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift new file mode 100644 index 0000000..0429fb9 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ChevronCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + + var detailMessage: String { + get { return detailTextLabel?.text ?? "" } + set(value) { detailTextLabel?.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: reuseIdentifier) + accessoryType = .disclosureIndicator + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift b/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift new file mode 100644 index 0000000..e065b4f --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class EditableTextCell: UITableViewCell { + var message: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + + var placeholder: String? { + get { return valueTextField.placeholder } + set(value) { valueTextField.placeholder = value } + } + + let valueTextField: UITextField = { + let valueTextField = UITextField() + valueTextField.textAlignment = .left + valueTextField.isEnabled = true + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + return valueTextField + }() + + var onValueBeingEdited: ((String) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + valueTextField.delegate = self + contentView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + // Reduce the bottom margin by 0.5pt to maintain the default cell height (44pt) + let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: valueTextField.bottomAnchor, constant: -0.5) + bottomAnchorConstraint.priority = .defaultLow + NSLayoutConstraint.activate([ + valueTextField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: valueTextField.trailingAnchor), + contentView.layoutMarginsGuide.topAnchor.constraint(equalTo: valueTextField.topAnchor), + bottomAnchorConstraint + ]) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func beginEditing() { + valueTextField.becomeFirstResponder() + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + placeholder = nil + } +} + +extension EditableTextCell: UITextFieldDelegate { + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift b/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift new file mode 100644 index 0000000..ab1a71f --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class KeyValueCell: UITableViewCell { + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + keyLabel.textColor = .label + keyLabel.textAlignment = .left + return keyLabel + }() + + let valueLabelScrollView: UIScrollView = { + let scrollView = UIScrollView(frame: .zero) + scrollView.isDirectionalLockEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + return scrollView + }() + + let valueTextField: UITextField = { + let valueTextField = KeyValueCellTextField() + valueTextField.textAlignment = .right + valueTextField.isEnabled = false + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + valueTextField.textColor = .secondaryLabel + return valueTextField + }() + + var copyableGesture = true + + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + var placeholderText: String { + get { return valueTextField.placeholder ?? "" } + set(value) { valueTextField.placeholder = value } + } + var keyboardType: UIKeyboardType { + get { return valueTextField.keyboardType } + set(value) { valueTextField.keyboardType = value } + } + + var isValueValid = true { + didSet { + if isValueValid { + keyLabel.textColor = .label + } else { + keyLabel.textColor = .systemRed + } + } + } + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + var onValueChanged: ((String, String) -> Void)? + var onValueBeingEdited: ((String) -> Void)? + + var observationToken: AnyObject? + + private var textFieldValueOnBeginEditing: String = "" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + keyLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ]) + + valueTextField.delegate = self + valueLabelScrollView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueTextField.leadingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.leadingAnchor), + valueTextField.topAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.topAnchor), + valueTextField.bottomAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.bottomAnchor), + valueTextField.trailingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.trailingAnchor), + valueTextField.heightAnchor.constraint(equalTo: valueLabelScrollView.heightAnchor) + ]) + let expandToFitValueLabelConstraint = NSLayoutConstraint(item: valueTextField, attribute: .width, relatedBy: .equal, toItem: valueLabelScrollView, attribute: .width, multiplier: 1, constant: 0) + expandToFitValueLabelConstraint.priority = .defaultLow + 1 + expandToFitValueLabelConstraint.isActive = true + + contentView.addSubview(valueLabelScrollView) + valueLabelScrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabelScrollView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabelScrollView.bottomAnchor, multiplier: 0.5) + ]) + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabelScrollView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + addGestureRecognizer(gestureRecognizer) + isUserInteractionEnabled = true + + configureForContentSize() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabelScrollView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + keyLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabelScrollView.leadingAnchor.constraint(equalToSystemSpacingAfter: keyLabel.trailingAnchor, multiplier: 1), + valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + contentSizeBasedConstraints = constraints + } + } + + @objc func handleTapGesture(_ recognizer: UIGestureRecognizer) { + if !copyableGesture { + return + } + guard recognizer.state == .recognized else { return } + + if let recognizerView = recognizer.view, + let recognizerSuperView = recognizerView.superview, recognizerView.becomeFirstResponder() { + let menuController = UIMenuController.shared + menuController.setTargetRect(detailTextLabel?.frame ?? recognizerView.frame, in: detailTextLabel?.superview ?? recognizerSuperView) + menuController.setMenuVisible(true, animated: true) + } + } + + override var canBecomeFirstResponder: Bool { + return true + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return (action == #selector(UIResponderStandardEditActions.copy(_:))) + } + + override func copy(_ sender: Any?) { + UIPasteboard.general.string = valueTextField.text + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = true + placeholderText = "" + isValueValid = true + keyboardType = .default + onValueChanged = nil + onValueBeingEdited = nil + observationToken = nil + key = "" + value = "" + configureForContentSize() + } +} + +extension KeyValueCell: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + textFieldValueOnBeginEditing = textField.text ?? "" + isValueValid = true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + let isModified = textField.text ?? "" != textFieldValueOnBeginEditing + guard isModified else { return } + onValueChanged?(textFieldValueOnBeginEditing, textField.text ?? "") + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } + +} + +class KeyValueCellTextField: UITextField { + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + // UIKit renders the placeholder label 0.5pt higher + return super.placeholderRect(forBounds: bounds).integral.offsetBy(dx: 0, dy: -0.5) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift b/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift new file mode 100644 index 0000000..4bedbba --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SwitchCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + var isOn: Bool { + get { return switchView.isOn } + set(value) { switchView.isOn = value } + } + var isEnabled: Bool { + get { return switchView.isEnabled } + set(value) { + switchView.isEnabled = value + textLabel?.textColor = value ? .label : .secondaryLabel + } + } + + var onSwitchToggled: ((Bool) -> Void)? + + var statusObservationToken: AnyObject? + var isOnDemandEnabledObservationToken: AnyObject? + var hasOnDemandRulesObservationToken: AnyObject? + + let switchView = UISwitch() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + + accessoryView = switchView + switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func switchToggled() { + onSwitchToggled?(switchView.isOn) + } + + override func prepareForReuse() { + super.prepareForReuse() + onSwitchToggled = nil + isEnabled = true + message = "" + isOn = false + statusObservationToken = nil + isOnDemandEnabledObservationToken = nil + hasOnDemandRulesObservationToken = nil + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TextCell.swift b/Sources/WireGuardApp/UI/iOS/View/TextCell.swift new file mode 100644 index 0000000..3024b46 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TextCell.swift @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TextCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTextColor(_ color: UIColor) { + textLabel?.textColor = color + } + + func setTextAlignment(_ alignment: NSTextAlignment) { + textLabel?.textAlignment = alignment + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + setTextColor(.label) + setTextAlignment(.left) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift b/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift new file mode 100644 index 0000000..d151402 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditKeyValueCell: KeyValueCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + keyLabel.textAlignment = .right + valueTextField.textAlignment = .left + + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 0.4, constant: 0) + // In case the key doesn't fit into 0.4 * width, + // set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + widthRatioConstraint.isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +class TunnelEditEditableKeyValueCell: TunnelEditKeyValueCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + copyableGesture = false + valueTextField.textColor = .label + valueTextField.isEnabled = true + valueLabelScrollView.isScrollEnabled = false + valueTextField.widthAnchor.constraint(equalTo: valueLabelScrollView.widthAnchor).isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = false + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift b/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift new file mode 100644 index 0000000..71ee6d8 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelListCell: UITableViewCell { + var tunnel: TunnelContainer? { + didSet { + // Bind to the tunnel's name + nameLabel.text = tunnel?.name ?? "" + nameObservationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in + self?.nameLabel.text = tunnel.name + } + // Bind to the tunnel's status + update(from: tunnel, animated: false) + statusObservationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in + self?.update(from: tunnel, animated: true) + } + // Bind to tunnel's on-demand settings + isOnDemandEnabledObservationToken = tunnel?.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + self?.update(from: tunnel, animated: true) + } + hasOnDemandRulesObservationToken = tunnel?.observe(\.hasOnDemandRules) { [weak self] tunnel, _ in + self?.update(from: tunnel, animated: true) + } + } + } + var onSwitchToggled: ((Bool) -> Void)? + + let nameLabel: UILabel = { + let nameLabel = UILabel() + nameLabel.font = UIFont.preferredFont(forTextStyle: .body) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.numberOfLines = 0 + return nameLabel + }() + + let onDemandLabel: UILabel = { + let label = UILabel() + label.text = "" + label.font = UIFont.preferredFont(forTextStyle: .caption2) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 1 + label.textColor = .secondaryLabel + return label + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator: UIActivityIndicatorView + busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + let statusSwitch = UISwitch() + + private var nameObservationToken: NSKeyValueObservation? + private var statusObservationToken: NSKeyValueObservation? + private var isOnDemandEnabledObservationToken: NSKeyValueObservation? + private var hasOnDemandRulesObservationToken: NSKeyValueObservation? + + private var subTitleLabelBottomConstraint: NSLayoutConstraint? + private var nameLabelBottomConstraint: NSLayoutConstraint? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + accessoryType = .disclosureIndicator + + for subview in [statusSwitch, busyIndicator, onDemandLabel, nameLabel] { + subview.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(subview) + } + + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + onDemandLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + let nameLabelBottomConstraint = + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1) + nameLabelBottomConstraint.priority = .defaultLow + + NSLayoutConstraint.activate([ + statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + statusSwitch.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.trailingAnchor, multiplier: 1), + statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: onDemandLabel.trailingAnchor, multiplier: 1), + + nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1), + nameLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1), + nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: statusSwitch.leadingAnchor), + nameLabelBottomConstraint, + + onDemandLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + onDemandLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1), + + busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + busyIndicator.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1) + ]) + + statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + reset(animated: false) + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + statusSwitch.isEnabled = !editing + } + + @objc private func switchToggled() { + onSwitchToggled?(statusSwitch.isOn) + } + + private func update(from tunnel: TunnelContainer?, animated: Bool) { + guard let tunnel = tunnel else { + reset(animated: animated) + return + } + let status = tunnel.status + let isOnDemandEngaged = tunnel.isActivateOnDemandEnabled + + let shouldSwitchBeOn = ((status != .deactivating && status != .inactive) || isOnDemandEngaged) + statusSwitch.setOn(shouldSwitchBeOn, animated: true) + + if isOnDemandEngaged && !(status == .activating || status == .active) { + statusSwitch.onTintColor = UIColor.systemYellow + } else { + statusSwitch.onTintColor = UIColor.systemGreen + } + + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + + if tunnel.hasOnDemandRules { + onDemandLabel.text = isOnDemandEngaged ? tr("tunnelListCaptionOnDemand") : "" + busyIndicator.stopAnimating() + statusSwitch.isUserInteractionEnabled = true + } else { + onDemandLabel.text = "" + if status == .inactive || status == .active { + busyIndicator.stopAnimating() + } else { + busyIndicator.startAnimating() + } + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + } + + } + + private func reset(animated: Bool) { + statusSwitch.thumbTintColor = nil + statusSwitch.setOn(false, animated: animated) + statusSwitch.isUserInteractionEnabled = false + busyIndicator.stopAnimating() + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift new file mode 100644 index 0000000..2398919 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class LogViewController: UIViewController { + + let textView: UITextView = { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + textView.adjustsFontForContentSizeCategory = true + return textView + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + let paragraphStyle: NSParagraphStyle = { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.setParagraphStyle(NSParagraphStyle.default) + paragraphStyle.lineHeightMultiple = 1.2 + return paragraphStyle + }() + + var isNextLineHighlighted = false + + var logViewHelper: LogViewHelper? + var isFetchingLogEntries = false + private var updateLogEntriesTimer: Timer? + + override func loadView() { + view = UIView() + view.backgroundColor = .systemBackground + view.addSubview(textView) + textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + view.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + busyIndicator.startAnimating() + + logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path) + startUpdatingLogEntries() + } + + override func viewDidLoad() { + title = tr("logViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped(sender:))) + } + + func updateLogEntries() { + guard !isFetchingLogEntries else { return } + isFetchingLogEntries = true + logViewHelper?.fetchLogEntriesSinceLastFetch { [weak self] fetchedLogEntries in + guard let self = self else { return } + defer { + self.isFetchingLogEntries = false + } + if self.busyIndicator.isAnimating { + self.busyIndicator.stopAnimating() + } + guard !fetchedLogEntries.isEmpty else { return } + let isScrolledToEnd = self.textView.contentSize.height - self.textView.bounds.height - self.textView.contentOffset.y < 1 + + let richText = NSMutableAttributedString() + let bodyFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + let captionFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.caption1) + for logEntry in fetchedLogEntries { + let bgColor: UIColor = self.isNextLineHighlighted ? .systemGray3 : .systemBackground + let fgColor: UIColor = .label + let timestampText = NSAttributedString(string: logEntry.timestamp + "\n", attributes: [.font: captionFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle]) + let messageText = NSAttributedString(string: logEntry.message + "\n", attributes: [.font: bodyFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle]) + richText.append(timestampText) + richText.append(messageText) + self.isNextLineHighlighted.toggle() + } + self.textView.textStorage.append(richText) + if isScrolledToEnd { + let endOfCurrentText = NSRange(location: (self.textView.text as NSString).length, length: 0) + self.textView.scrollRangeToVisible(endOfCurrentText) + } + } + } + + func startUpdatingLogEntries() { + updateLogEntries() + updateLogEntriesTimer?.invalidate() + let timer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in + self?.updateLogEntries() + } + updateLogEntriesTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + @objc func saveTapped(sender: AnyObject) { + guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename + let timeStampString = dateFormatter.string(from: Date()) + let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt") + + DispatchQueue.global(qos: .userInitiated).async { + + if FileManager.default.fileExists(atPath: destinationURL.path) { + let isDeleted = FileManager.deleteFile(at: destinationURL) + if !isDeleted { + ErrorPresenter.showErrorAlert(title: tr("alertUnableToRemovePreviousLogTitle"), message: tr("alertUnableToRemovePreviousLogMessage"), from: self) + return + } + } + + let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false + + DispatchQueue.main.async { + guard isWritten else { + ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self) + return + } + let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) + if let sender = sender as? UIBarButtonItem { + activityVC.popoverPresentationController?.barButtonItem = sender + } + activityVC.completionWithItemsHandler = { _, _, _, _ in + // Remove the exported log file after the activity has completed + _ = FileManager.deleteFile(at: destinationURL) + } + self.present(activityVC, animated: true) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift new file mode 100644 index 0000000..8542296 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class MainViewController: UISplitViewController { + + var tunnelsManager: TunnelsManager? + var onTunnelsManagerReady: ((TunnelsManager) -> Void)? + var tunnelsListVC: TunnelsListTableViewController? + + init() { + let detailVC = UIViewController() + detailVC.view.backgroundColor = .systemBackground + let detailNC = UINavigationController(rootViewController: detailVC) + + let masterVC = TunnelsListTableViewController() + let masterNC = UINavigationController(rootViewController: masterVC) + + tunnelsListVC = masterVC + + super.init(nibName: nil, bundle: nil) + + viewControllers = [ masterNC, detailNC ] + + restorationIdentifier = "MainVC" + masterNC.restorationIdentifier = "MasterNC" + detailNC.restorationIdentifier = "DetailNC" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + delegate = self + + // On iPad, always show both masterVC and detailVC, even in portrait mode, like the Settings app + preferredDisplayMode = .allVisible + + // Create the tunnels manager, and when it's ready, inform tunnelsListVC + TunnelsManager.create { [weak self] result in + guard let self = self else { return } + + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: self) + case .success(let tunnelsManager): + self.tunnelsManager = tunnelsManager + self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager) + + tunnelsManager.activationDelegate = self + + self.onTunnelsManagerReady?(tunnelsManager) + self.onTunnelsManagerReady = nil + } + } + } + + func allTunnelNames() -> [String]? { + guard let tunnelsManager = self.tunnelsManager else { return nil } + return tunnelsManager.mapTunnels { $0.name } + } +} + +extension MainViewController: TunnelsManagerActivationDelegate { + func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) { + ErrorPresenter.showErrorAlert(error: error, from: self) + } + + func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) { + // Nothing to do + } + + func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) { + ErrorPresenter.showErrorAlert(error: error, from: self) + } + + func tunnelActivationSucceeded(tunnel: TunnelContainer) { + // Nothing to do + } +} + +extension MainViewController { + func refreshTunnelConnectionStatuses() { + if let tunnelsManager = tunnelsManager { + tunnelsManager.refreshStatuses() + } + } + + func showTunnelDetailForTunnel(named tunnelName: String, animated: Bool, shouldToggleStatus: Bool) { + let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + guard let self = self else { return } + guard let tunnelsListVC = self.tunnelsListVC else { return } + if let tunnel = tunnelsManager.tunnel(named: tunnelName) { + tunnelsListVC.showTunnelDetail(for: tunnel, animated: false) + if shouldToggleStatus { + if tunnel.status == .inactive { + tunnelsManager.startActivation(of: tunnel) + } else if tunnel.status == .active { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } + } + if let tunnelsManager = tunnelsManager { + showTunnelDetailBlock(tunnelsManager) + } else { + onTunnelsManagerReady = showTunnelDetailBlock + } + } + + func importFromDisposableFile(url: URL) { + let importFromFileBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + TunnelImporter.importFromFile(urls: [url], into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) { + _ = FileManager.deleteFile(at: url) + } + } + if let tunnelsManager = tunnelsManager { + importFromFileBlock(tunnelsManager) + } else { + onTunnelsManagerReady = importFromFileBlock + } + } +} + +extension MainViewController: UISplitViewControllerDelegate { + func splitViewController(_ splitViewController: UISplitViewController, + collapseSecondary secondaryViewController: UIViewController, + onto primaryViewController: UIViewController) -> Bool { + // On iPhone, if the secondaryVC (detailVC) is just a UIViewController, it indicates that it's empty, + // so just show the primaryVC (masterVC). + let detailVC = (secondaryViewController as? UINavigationController)?.viewControllers.first + let isDetailVCEmpty: Bool + if let detailVC = detailVC { + isDetailVCEmpty = (type(of: detailVC) == UIViewController.self) + } else { + isDetailVCEmpty = true + } + return isDetailVCEmpty + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift new file mode 100644 index 0000000..cb297a6 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import AVFoundation +import UIKit + +protocol QRScanViewControllerDelegate: AnyObject { + func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, completionHandler: (() -> Void)?) +} + +class QRScanViewController: UIViewController { + weak var delegate: QRScanViewControllerDelegate? + var captureSession: AVCaptureSession? = AVCaptureSession() + let metadataOutput = AVCaptureMetadataOutput() + var previewLayer: AVCaptureVideoPreviewLayer? + + override func viewDidLoad() { + super.viewDidLoad() + + title = tr("scanQRCodeViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + let tipLabel = UILabel() + tipLabel.text = tr("scanQRCodeTipText") + tipLabel.adjustsFontSizeToFitWidth = true + tipLabel.textColor = .lightGray + tipLabel.textAlignment = .center + + view.addSubview(tipLabel) + tipLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tipLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + tipLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + tipLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32) + ]) + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + let captureSession = captureSession, + captureSession.canAddInput(videoInput), + captureSession.canAddOutput(metadataOutput) else { + scanDidEncounterError(title: tr("alertScanQRCodeCameraUnsupportedTitle"), message: tr("alertScanQRCodeCameraUnsupportedMessage")) + return + } + + captureSession.addInput(videoInput) + captureSession.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + metadataOutput.metadataObjectTypes = [.qr] + + let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.frame = view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(previewLayer, at: 0) + self.previewLayer = previewLayer + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if captureSession?.isRunning == false { + captureSession?.startRunning() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if captureSession?.isRunning == true { + captureSession?.stopRunning() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if let connection = previewLayer?.connection { + let currentDevice = UIDevice.current + let orientation = currentDevice.orientation + let previewLayerConnection = connection + + if previewLayerConnection.isVideoOrientationSupported { + switch orientation { + case .portrait: + previewLayerConnection.videoOrientation = .portrait + case .landscapeRight: + previewLayerConnection.videoOrientation = .landscapeLeft + case .landscapeLeft: + previewLayerConnection.videoOrientation = .landscapeRight + case .portraitUpsideDown: + previewLayerConnection.videoOrientation = .portraitUpsideDown + default: + previewLayerConnection.videoOrientation = .portrait + + } + } + } + + previewLayer?.frame = view.bounds + } + + func scanDidComplete(withCode code: String) { + let scannedTunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: code, called: "Scanned") + guard let tunnelConfiguration = scannedTunnelConfiguration else { + scanDidEncounterError(title: tr("alertScanQRCodeInvalidQRCodeTitle"), message: tr("alertScanQRCodeInvalidQRCodeMessage")) + return + } + + let alert = UIAlertController(title: tr("alertScanQRCodeNamePromptTitle"), message: nil, preferredStyle: .alert) + alert.addTextField(configurationHandler: nil) + alert.addAction(UIAlertAction(title: tr("actionCancel"), style: .cancel) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + }) + alert.addAction(UIAlertAction(title: tr("actionSave"), style: .default) { [weak self] _ in + guard let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return } + tunnelConfiguration.name = title + if let self = self { + self.delegate?.addScannedQRCode(tunnelConfiguration: tunnelConfiguration, qrScanViewController: self) { + self.dismiss(animated: true, completion: nil) + } + } + }) + present(alert, animated: true) + } + + func scanDidEncounterError(title: String, message: String) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: tr("actionOK"), style: .default) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + }) + present(alertController, animated: true) + captureSession = nil + } + + @objc func cancelTapped() { + dismiss(animated: true, completion: nil) + } +} + +extension QRScanViewController: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + captureSession?.stopRunning() + + guard let metadataObject = metadataObjects.first, + let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, + let stringValue = readableObject.stringValue else { + scanDidEncounterError(title: tr("alertScanQRCodeUnreadableQRCodeTitle"), message: tr("alertScanQRCodeUnreadableQRCodeMessage")) + return + } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + scanDidComplete(withCode: stringValue) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift new file mode 100644 index 0000000..4211560 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SSIDOptionDetailTableViewController: UITableViewController { + + let selectedSSIDs: [String] + + init(title: String, ssids: [String]) { + selectedSSIDs = ssids + super.init(style: .grouped) + self.title = title + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + + tableView.register(TextCell.self) + } +} + +extension SSIDOptionDetailTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return selectedSSIDs.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return tr("tunnelOnDemandSectionTitleSelectedSSIDs") + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = selectedSSIDs[indexPath.row] + return cell + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift new file mode 100644 index 0000000..ef9a88c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import SystemConfiguration.CaptiveNetwork +import NetworkExtension + +protocol SSIDOptionEditTableViewControllerDelegate: AnyObject { + func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) +} + +class SSIDOptionEditTableViewController: UITableViewController { + private enum Section { + case ssidOption + case selectedSSIDs + case addSSIDs + } + + private enum AddSSIDRow { + case addConnectedSSID(connectedSSID: String) + case addNewSSID + } + + weak var delegate: SSIDOptionEditTableViewControllerDelegate? + + private var sections = [Section]() + private var addSSIDRows = [AddSSIDRow]() + + let ssidOptionFields: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [ + .anySSID, + .onlySpecificSSIDs, + .exceptSpecificSSIDs + ] + + var selectedOption: ActivateOnDemandViewModel.OnDemandSSIDOption + var selectedSSIDs: [String] + var connectedSSID: String? + + init(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) { + selectedOption = option + selectedSSIDs = ssids + super.init(style: .grouped) + loadSections() + addSSIDRows.removeAll() + addSSIDRows.append(.addNewSSID) + + getConnectedSSID { [weak self] ssid in + guard let self = self else { return } + self.connectedSSID = ssid + self.updateCurrentSSIDEntry() + self.updateTableViewAddSSIDRows() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tr("tunnelOnDemandSSIDViewTitle") + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + + tableView.register(CheckmarkCell.self) + tableView.register(EditableTextCell.self) + tableView.register(TextCell.self) + tableView.isEditing = true + tableView.allowsSelectionDuringEditing = true + tableView.keyboardDismissMode = .onDrag + } + + func loadSections() { + sections.removeAll() + sections.append(.ssidOption) + if selectedOption != .anySSID { + sections.append(.selectedSSIDs) + sections.append(.addSSIDs) + } + } + + func updateCurrentSSIDEntry() { + if let connectedSSID = connectedSSID, !selectedSSIDs.contains(connectedSSID) { + if let first = addSSIDRows.first, case .addNewSSID = first { + addSSIDRows.insert(.addConnectedSSID(connectedSSID: connectedSSID), at: 0) + } + } else if let first = addSSIDRows.first, case .addConnectedSSID = first { + addSSIDRows.removeFirst() + } + } + + func updateTableViewAddSSIDRows() { + guard let addSSIDSection = sections.firstIndex(of: .addSSIDs) else { return } + let numberOfAddSSIDRows = addSSIDRows.count + let numberOfAddSSIDRowsInTableView = tableView.numberOfRows(inSection: addSSIDSection) + switch (numberOfAddSSIDRowsInTableView, numberOfAddSSIDRows) { + case (1, 2): + tableView.insertRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic) + case (2, 1): + tableView.deleteRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic) + default: + break + } + } + + override func viewWillDisappear(_ animated: Bool) { + delegate?.ssidOptionSaved(option: selectedOption, ssids: selectedSSIDs) + } +} + +extension SSIDOptionEditTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .ssidOption: + return ssidOptionFields.count + case .selectedSSIDs: + return selectedSSIDs.isEmpty ? 1 : selectedSSIDs.count + case .addSSIDs: + return addSSIDRows.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .ssidOption: + return ssidOptionCell(for: tableView, at: indexPath) + case .selectedSSIDs: + if !selectedSSIDs.isEmpty { + return selectedSSIDCell(for: tableView, at: indexPath) + } else { + return noSSIDsCell(for: tableView, at: indexPath) + } + case .addSSIDs: + return addSSIDCell(for: tableView, at: indexPath) + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + switch sections[indexPath.section] { + case .ssidOption: + return false + case .selectedSSIDs: + return !selectedSSIDs.isEmpty + case .addSSIDs: + return true + } + } + + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + switch sections[indexPath.section] { + case .ssidOption: + return .none + case .selectedSSIDs: + return .delete + case .addSSIDs: + return .insert + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .ssidOption: + return nil + case .selectedSSIDs: + return tr("tunnelOnDemandSectionTitleSelectedSSIDs") + case .addSSIDs: + return tr("tunnelOnDemandSectionTitleAddSSIDs") + } + } + + private func ssidOptionCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = ssidOptionFields[indexPath.row] + let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isChecked = selectedOption == field + cell.isEditing = false + return cell + } + + private func noSSIDsCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = tr("tunnelOnDemandNoSSIDs") + cell.setTextColor(.secondaryLabel) + cell.setTextAlignment(.center) + return cell + } + + private func selectedSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: EditableTextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = selectedSSIDs[indexPath.row] + cell.placeholder = tr("tunnelOnDemandSSIDTextFieldPlaceholder") + cell.isEditing = true + cell.onValueBeingEdited = { [weak self, weak cell] text in + guard let self = self, let cell = cell else { return } + if let row = self.tableView.indexPath(for: cell)?.row { + self.selectedSSIDs[row] = text + self.updateCurrentSSIDEntry() + self.updateTableViewAddSSIDRows() + } + } + return cell + } + + private func addSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + switch addSSIDRows[indexPath.row] { + case .addConnectedSSID: + cell.message = tr(format: "tunnelOnDemandAddMessageAddConnectedSSID (%@)", connectedSSID!) + case .addNewSSID: + cell.message = tr("tunnelOnDemandAddMessageAddNewSSID") + } + cell.isEditing = true + return cell + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .ssidOption: + assertionFailure() + case .selectedSSIDs: + assert(editingStyle == .delete) + selectedSSIDs.remove(at: indexPath.row) + if !selectedSSIDs.isEmpty { + tableView.deleteRows(at: [indexPath], with: .automatic) + } else { + tableView.reloadRows(at: [indexPath], with: .automatic) + } + updateCurrentSSIDEntry() + updateTableViewAddSSIDRows() + case .addSSIDs: + assert(editingStyle == .insert) + let newSSID: String + switch addSSIDRows[indexPath.row] { + case .addConnectedSSID(let connectedSSID): + newSSID = connectedSSID + case .addNewSSID: + newSSID = "" + } + selectedSSIDs.append(newSSID) + loadSections() + let selectedSSIDsSection = sections.firstIndex(of: .selectedSSIDs)! + let indexPath = IndexPath(row: selectedSSIDs.count - 1, section: selectedSSIDsSection) + if selectedSSIDs.count == 1 { + tableView.reloadRows(at: [indexPath], with: .automatic) + } else { + tableView.insertRows(at: [indexPath], with: .automatic) + } + updateCurrentSSIDEntry() + updateTableViewAddSSIDRows() + if newSSID.isEmpty { + if let selectedSSIDCell = tableView.cellForRow(at: indexPath) as? EditableTextCell { + selectedSSIDCell.beginEditing() + } + } + } + } + + private func getConnectedSSID(completionHandler: @escaping (String?) -> Void) { + #if targetEnvironment(simulator) + completionHandler("Simulator Wi-Fi") + #else + NEHotspotNetwork.fetchCurrent { hotspotNetwork in + completionHandler(hotspotNetwork?.ssid) + } + #endif + } +} + +extension SSIDOptionEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + switch sections[indexPath.section] { + case .ssidOption: + return indexPath + case .selectedSSIDs, .addSSIDs: + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .ssidOption: + let previousOption = selectedOption + selectedOption = ssidOptionFields[indexPath.row] + guard previousOption != selectedOption else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + loadSections() + if previousOption == .anySSID { + let indexSet = IndexSet(1 ... 2) + tableView.insertSections(indexSet, with: .fade) + } + if selectedOption == .anySSID { + let indexSet = IndexSet(1 ... 2) + tableView.deleteSections(indexSet, with: .fade) + } + tableView.reloadSections(IndexSet(integer: indexPath.section), with: .none) + case .selectedSSIDs, .addSSIDs: + assertionFailure() + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift new file mode 100644 index 0000000..483b779 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +class SettingsTableViewController: UITableViewController { + + enum SettingsFields { + case iosAppVersion + case goBackendVersion + case exportZipArchive + case viewLog + + var localizedUIString: String { + switch self { + case .iosAppVersion: return tr("settingsVersionKeyWireGuardForIOS") + case .goBackendVersion: return tr("settingsVersionKeyWireGuardGoBackend") + case .exportZipArchive: return tr("settingsExportZipButtonTitle") + case .viewLog: return tr("settingsViewLogButtonTitle") + } + } + } + + let settingsFieldsBySection: [[SettingsFields]] = [ + [.iosAppVersion, .goBackendVersion], + [.exportZipArchive], + [.viewLog] + ] + + let tunnelsManager: TunnelsManager? + var wireguardCaptionedImage: (view: UIView, size: CGSize)? + + init(tunnelsManager: TunnelsManager?) { + self.tunnelsManager = tunnelsManager + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tr("settingsViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + + tableView.register(KeyValueCell.self) + tableView.register(ButtonCell.self) + + tableView.tableFooterView = UIImageView(image: UIImage(named: "wireguard.pdf")) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let logo = tableView.tableFooterView else { return } + + let bottomPadding = max(tableView.layoutMargins.bottom, 10) + let fullHeight = max(tableView.contentSize.height, tableView.bounds.size.height - tableView.layoutMargins.top - bottomPadding) + + let imageAspectRatio = logo.intrinsicContentSize.width / logo.intrinsicContentSize.height + + var height = tableView.estimatedRowHeight * 1.5 + var width = height * imageAspectRatio + let maxWidth = view.bounds.size.width - max(tableView.layoutMargins.left + tableView.layoutMargins.right, 20) + if width > maxWidth { + width = maxWidth + height = width / imageAspectRatio + } + + let needsReload = height != logo.frame.height + + logo.frame = CGRect(x: (view.bounds.size.width - width) / 2, y: fullHeight - height, width: width, height: height) + + if needsReload { + tableView.tableFooterView = logo + } + } + + @objc func doneTapped() { + dismiss(animated: true, completion: nil) + } + + func exportConfigurationsAsZipFile(sourceView: UIView) { + PrivateDataConfirmation.confirmAccess(to: tr("iosExportPrivateData")) { [weak self] in + guard let self = self else { return } + guard let tunnelsManager = self.tunnelsManager else { return } + guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip") + _ = FileManager.deleteFile(at: destinationURL) + + let count = tunnelsManager.numberOfTunnels() + let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration } + ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + + let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService) + self?.present(fileExportVC, animated: true, completion: nil) + } + } + } + + func presentLogView() { + let logVC = LogViewController() + navigationController?.pushViewController(logVC, animated: true) + + } +} + +extension SettingsTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return settingsFieldsBySection.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return settingsFieldsBySection[section].count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0: + return tr("settingsSectionTitleAbout") + case 1: + return tr("settingsSectionTitleExportConfigurations") + case 2: + return tr("settingsSectionTitleTunnelLog") + default: + return nil + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let field = settingsFieldsBySection[indexPath.section][indexPath.row] + if field == .iosAppVersion || field == .goBackendVersion { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.copyableGesture = false + cell.key = field.localizedUIString + if field == .iosAppVersion { + var appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown version" + if let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { + appVersion += " (\(appBuild))" + } + cell.value = appVersion + } else if field == .goBackendVersion { + cell.value = WIREGUARD_GO_VERSION + } + return cell + } else if field == .exportZipArchive { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + self?.exportConfigurationsAsZipFile(sourceView: cell.button) + } + return cell + } else if field == .viewLog { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + self?.presentLogView() + } + return cell + } + fatalError() + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift new file mode 100644 index 0000000..509d123 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelDetailTableViewController: UITableViewController { + + private enum Section { + case status + case interface + case peer(index: Int, peer: TunnelViewModel.PeerData) + case onDemand + case delete + } + + static let interfaceFields: [TunnelViewModel.InterfaceField] = [ + .name, .publicKey, .addresses, + .listenPort, .mtu, .dns + ] + + static let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .persistentKeepAlive, + .rxBytes, .txBytes, .lastHandshakeTime + ] + + static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [ + .onDemand, .ssid + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer + var tunnelViewModel: TunnelViewModel + var onDemandViewModel: ActivateOnDemandViewModel + + private var sections = [Section]() + private var interfaceFieldIsVisible = [Bool]() + private var peerFieldIsVisible = [[Bool]]() + + private var statusObservationToken: AnyObject? + private var onDemandObservationToken: AnyObject? + private var reloadRuntimeConfigurationTimer: Timer? + + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + super.init(style: .grouped) + loadSections() + loadVisibleFields() + statusObservationToken = tunnel.observe(\.status) { [weak self] _, _ in + guard let self = self else { return } + if tunnel.status == .active { + self.startUpdatingRuntimeConfiguration() + } else if tunnel.status == .inactive { + self.reloadRuntimeConfiguration() + self.stopUpdatingRuntimeConfiguration() + } + } + onDemandObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + // Handle On-Demand getting turned on/off outside of the app + self?.onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + self?.updateActivateOnDemandFields() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tunnelViewModel.interfaceData[.name] + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.register(SwitchCell.self) + tableView.register(KeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(ChevronCell.self) + + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + } + + private func loadSections() { + sections.removeAll() + sections.append(.status) + sections.append(.interface) + for (index, peer) in tunnelViewModel.peersData.enumerated() { + sections.append(.peer(index: index, peer: peer)) + } + sections.append(.onDemand) + sections.append(.delete) + } + + private func loadVisibleFields() { + let visibleInterfaceFields = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: TunnelDetailTableViewController.interfaceFields) + interfaceFieldIsVisible = TunnelDetailTableViewController.interfaceFields.map { visibleInterfaceFields.contains($0) } + peerFieldIsVisible = tunnelViewModel.peersData.map { peer in + let visiblePeerFields = peer.filterFieldsWithValueOrControl(peerFields: TunnelDetailTableViewController.peerFields) + return TunnelDetailTableViewController.peerFields.map { visiblePeerFields.contains($0) } + } + } + + override func viewWillAppear(_ animated: Bool) { + if tunnel.status == .active { + self.startUpdatingRuntimeConfiguration() + } + } + + override func viewDidDisappear(_ animated: Bool) { + stopUpdatingRuntimeConfiguration() + } + + @objc func editTapped() { + PrivateDataConfirmation.confirmAccess(to: tr("iosViewPrivateData")) { [weak self] in + guard let self = self else { return } + let editVC = TunnelEditTableViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel) + editVC.delegate = self + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .fullScreen + self.present(editNC, animated: true) + } + } + + func startUpdatingRuntimeConfiguration() { + reloadRuntimeConfiguration() + reloadRuntimeConfigurationTimer?.invalidate() + let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in + self?.reloadRuntimeConfiguration() + } + reloadRuntimeConfigurationTimer = reloadTimer + RunLoop.main.add(reloadTimer, forMode: .common) + } + + func stopUpdatingRuntimeConfiguration() { + reloadRuntimeConfigurationTimer?.invalidate() + reloadRuntimeConfigurationTimer = nil + } + + func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) { + // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering. + guard let tableView = self.tableView else { return } + let sections = self.sections + let interfaceSectionIndex = sections.firstIndex { + if case .interface = $0 { + return true + } else { + return false + } + }! + let firstPeerSectionIndex = interfaceSectionIndex + 1 + let interfaceFieldIsVisible = self.interfaceFieldIsVisible + let peerFieldIsVisible = self.peerFieldIsVisible + + func handleSectionFieldsModified<T>(fields: [T], fieldIsVisible: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) { + for (index, field) in fields.enumerated() { + guard let change = changes[field] else { continue } + if case .modified(let newValue) = change { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + let indexPath = IndexPath(row: row, section: section) + if let cell = tableView.cellForRow(at: indexPath) as? KeyValueCell { + cell.value = newValue + } + } + } + } + + func handleSectionRowsInsertedOrRemoved<T>(fields: [T], fieldIsVisible fieldIsVisibleInput: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) { + var fieldIsVisible = fieldIsVisibleInput + + var removedIndexPaths = [IndexPath]() + for (index, field) in fields.enumerated().reversed() where changes[field] == .removed { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + removedIndexPaths.append(IndexPath(row: row, section: section)) + fieldIsVisible[index] = false + } + if !removedIndexPaths.isEmpty { + tableView.deleteRows(at: removedIndexPaths, with: .automatic) + } + + var addedIndexPaths = [IndexPath]() + for (index, field) in fields.enumerated() where changes[field] == .added { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + addedIndexPaths.append(IndexPath(row: row, section: section)) + fieldIsVisible[index] = true + } + if !addedIndexPaths.isEmpty { + tableView.insertRows(at: addedIndexPaths, with: .automatic) + } + } + + let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration) + + if !changes.interfaceChanges.isEmpty { + handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, + section: interfaceSectionIndex, changes: changes.interfaceChanges) + } + for (peerIndex, peerChanges) in changes.peerChanges { + handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges) + } + + let isAnyInterfaceFieldAddedOrRemoved = changes.interfaceChanges.contains { $0.value == .added || $0.value == .removed } + let isAnyPeerFieldAddedOrRemoved = changes.peerChanges.contains { $0.changes.contains { $0.value == .added || $0.value == .removed } } + let peersRemovedSectionIndices = changes.peersRemovedIndices.map { firstPeerSectionIndex + $0 } + let peersInsertedSectionIndices = changes.peersInsertedIndices.map { firstPeerSectionIndex + $0 } + + if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !peersRemovedSectionIndices.isEmpty || !peersInsertedSectionIndices.isEmpty { + tableView.beginUpdates() + if isAnyInterfaceFieldAddedOrRemoved { + handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, section: interfaceSectionIndex, changes: changes.interfaceChanges) + } + if isAnyPeerFieldAddedOrRemoved { + for (peerIndex, peerChanges) in changes.peerChanges { + handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges) + } + } + if !peersRemovedSectionIndices.isEmpty { + tableView.deleteSections(IndexSet(peersRemovedSectionIndices), with: .automatic) + } + if !peersInsertedSectionIndices.isEmpty { + tableView.insertSections(IndexSet(peersInsertedSectionIndices), with: .automatic) + } + self.loadSections() + self.loadVisibleFields() + tableView.endUpdates() + } else { + self.loadSections() + self.loadVisibleFields() + } + } + + private func reloadRuntimeConfiguration() { + tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in + guard let tunnelConfiguration = tunnelConfiguration else { return } + guard let self = self else { return } + self.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration) + } + } + + private func updateActivateOnDemandFields() { + guard let onDemandSection = sections.firstIndex(where: { if case .onDemand = $0 { return true } else { return false } }) else { return } + let numberOfTableViewOnDemandRows = tableView.numberOfRows(inSection: onDemandSection) + let ssidRowIndexPath = IndexPath(row: 1, section: onDemandSection) + switch (numberOfTableViewOnDemandRows, onDemandViewModel.isWiFiInterfaceEnabled) { + case (1, true): + tableView.insertRows(at: [ssidRowIndexPath], with: .automatic) + case (2, false): + tableView.deleteRows(at: [ssidRowIndexPath], with: .automatic) + default: + break + } + tableView.reloadSections(IndexSet(integer: onDemandSection), with: .automatic) + } +} + +extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate { + func tunnelSaved(tunnel: TunnelContainer) { + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + loadSections() + loadVisibleFields() + title = tunnel.name + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + tableView.reloadData() + } + func tunnelEditingCancelled() { + // Nothing to do + } +} + +extension TunnelDetailTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .status: + return 1 + case .interface: + return interfaceFieldIsVisible.filter { $0 }.count + case .peer(let peerIndex, _): + return peerFieldIsVisible[peerIndex].filter { $0 }.count + case .onDemand: + return onDemandViewModel.isWiFiInterfaceEnabled ? 2 : 1 + case .delete: + return 1 + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .status: + return tr("tunnelSectionTitleStatus") + case .interface: + return tr("tunnelSectionTitleInterface") + case .peer: + return tr("tunnelSectionTitlePeer") + case .onDemand: + return tr("tunnelSectionTitleOnDemand") + case .delete: + return nil + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .status: + return statusCell(for: tableView, at: indexPath) + case .interface: + return interfaceCell(for: tableView, at: indexPath) + case .peer(let index, let peer): + return peerCell(for: tableView, at: indexPath, with: peer, peerIndex: index) + case .onDemand: + return onDemandCell(for: tableView, at: indexPath) + case .delete: + return deleteConfigurationCell(for: tableView, at: indexPath) + } + } + + private func statusCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + + func update(cell: SwitchCell?, with tunnel: TunnelContainer) { + guard let cell = cell else { return } + + let status = tunnel.status + let isOnDemandEngaged = tunnel.isActivateOnDemandEnabled + + let isSwitchOn = (status == .activating || status == .active || isOnDemandEngaged) + cell.switchView.setOn(isSwitchOn, animated: true) + + if isOnDemandEngaged && !(status == .activating || status == .active) { + cell.switchView.onTintColor = UIColor.systemYellow + } else { + cell.switchView.onTintColor = UIColor.systemGreen + } + + var text: String + switch status { + case .inactive: + text = tr("tunnelStatusInactive") + case .activating: + text = tr("tunnelStatusActivating") + case .active: + text = tr("tunnelStatusActive") + case .deactivating: + text = tr("tunnelStatusDeactivating") + case .reasserting: + text = tr("tunnelStatusReasserting") + case .restarting: + text = tr("tunnelStatusRestarting") + case .waiting: + text = tr("tunnelStatusWaiting") + } + + if tunnel.hasOnDemandRules { + text += isOnDemandEngaged ? tr("tunnelStatusAddendumOnDemand") : "" + cell.switchView.isUserInteractionEnabled = true + cell.isEnabled = true + } else { + cell.switchView.isUserInteractionEnabled = (status == .inactive || status == .active) + cell.isEnabled = (status == .inactive || status == .active) + } + + if tunnel.hasOnDemandRules && !isOnDemandEngaged && status == .inactive { + text = tr("tunnelStatusOnDemandDisabled") + } + + cell.textLabel?.text = text + } + + update(cell: cell, with: tunnel) + cell.statusObservationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in + update(cell: cell, with: tunnel) + } + cell.isOnDemandEnabledObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak cell] tunnel, _ in + update(cell: cell, with: tunnel) + } + cell.hasOnDemandRulesObservationToken = tunnel.observe(\.hasOnDemandRules) { [weak cell] tunnel, _ in + update(cell: cell, with: tunnel) + } + + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + + if self.tunnel.hasOnDemandRules { + self.tunnelsManager.setOnDemandEnabled(isOn, on: self.tunnel) { error in + if error == nil && !isOn { + self.tunnelsManager.startDeactivation(of: self.tunnel) + } + } + } else { + if isOn { + self.tunnelsManager.startActivation(of: self.tunnel) + } else { + self.tunnelsManager.startDeactivation(of: self.tunnel) + } + } + } + return cell + } + + private func interfaceCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let visibleInterfaceFields = TunnelDetailTableViewController.interfaceFields.enumerated().filter { interfaceFieldIsVisible[$0.offset] }.map { $0.element } + let field = visibleInterfaceFields[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData, peerIndex: Int) -> UITableViewCell { + let visiblePeerFields = TunnelDetailTableViewController.peerFields.enumerated().filter { peerFieldIsVisible[peerIndex][$0.offset] }.map { $0.element } + let field = visiblePeerFields[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + if field == .persistentKeepAlive { + cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field]) + } else if field == .preSharedKey { + cell.value = tr("tunnelPeerPresharedKeyEnabled") + } else { + cell.value = peerData[field] + } + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = TunnelDetailTableViewController.onDemandFields[indexPath.row] + if field == .onDemand { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = onDemandViewModel.localizedInterfaceDescription + cell.copyableGesture = false + return cell + } else { + assert(field == .ssid) + if onDemandViewModel.ssidOption == .anySSID { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = onDemandViewModel.ssidOption.localizedUIString + cell.copyableGesture = false + return cell + } else { + let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.detailMessage = onDemandViewModel.localizedSSIDDescription + return cell + } + } + } + + private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = tr("deleteTunnelButtonTitle") + cell.hasDestructiveAction = true + cell.onTapped = { [weak self] in + guard let self = self else { return } + ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deleteTunnelConfirmationAlertMessage"), + buttonTitle: tr("deleteTunnelConfirmationAlertButtonTitle"), + from: cell, presentingVC: self) { [weak self] in + guard let self = self else { return } + self.tunnelsManager.remove(tunnel: self.tunnel) { error in + if error != nil { + print("Error removing tunnel: \(String(describing: error))") + return + } + } + } + } + return cell + } + +} + +extension TunnelDetailTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], + case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] { + return indexPath + } + return nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if case .onDemand = sections[indexPath.section], + case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] { + let ssidDetailVC = SSIDOptionDetailTableViewController(title: onDemandViewModel.ssidOption.localizedUIString, ssids: onDemandViewModel.selectedSSIDs) + navigationController?.pushViewController(ssidDetailVC, animated: true) + } + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift new file mode 100644 index 0000000..dfb35c6 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +protocol TunnelEditTableViewControllerDelegate: AnyObject { + func tunnelSaved(tunnel: TunnelContainer) + func tunnelEditingCancelled() +} + +class TunnelEditTableViewController: UITableViewController { + private enum Section { + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case addPeer + case onDemand + + static func == (lhs: Section, rhs: Section) -> Bool { + switch (lhs, rhs) { + case (.interface, .interface), + (.addPeer, .addPeer), + (.onDemand, .onDemand): + return true + case let (.peer(peerA), .peer(peerB)): + return peerA.index == peerB.index + default: + return false + } + } + } + + weak var delegate: TunnelEditTableViewControllerDelegate? + + let interfaceFieldsBySection: [[TunnelViewModel.InterfaceField]] = [ + [.name], + [.privateKey, .publicKey, .generateKeyPair], + [.addresses, .listenPort, .mtu, .dns] + ] + + let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .excludePrivateIPs, .persistentKeepAlive, + .deletePeer + ] + + let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [ + .nonWiFiInterface, + .wiFiInterface, + .ssid + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer? + let tunnelViewModel: TunnelViewModel + var onDemandViewModel: ActivateOnDemandViewModel + private var sections = [Section]() + + // Use this initializer to edit an existing tunnel. + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + super.init(style: .grouped) + loadSections() + } + + // Use this initializer to create a new tunnel. + init(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + tunnel = nil + tunnelViewModel = TunnelViewModel(tunnelConfiguration: nil) + onDemandViewModel = ActivateOnDemandViewModel() + super.init(style: .grouped) + loadSections() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tunnel == nil ? tr("newTunnelViewTitle") : tr("editTunnelViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + + tableView.register(TunnelEditKeyValueCell.self) + tableView.register(TunnelEditEditableKeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(SwitchCell.self) + tableView.register(ChevronCell.self) + } + + private func loadSections() { + sections.removeAll() + interfaceFieldsBySection.forEach { _ in sections.append(.interface) } + tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } + sections.append(.addPeer) + sections.append(.onDemand) + } + + @objc func saveTapped() { + tableView.endEditing(false) + let tunnelSaveResult = tunnelViewModel.save() + switch tunnelSaveResult { + case .error(let errorMessage): + let alertTitle = (tunnelViewModel.interfaceData.validatedConfiguration == nil || tunnelViewModel.interfaceData.validatedName == nil) ? + tr("alertInvalidInterfaceTitle") : tr("alertInvalidPeerTitle") + ErrorPresenter.showErrorAlert(title: alertTitle, message: errorMessage, from: self) + tableView.reloadData() // Highlight erroring fields + case .saved(let tunnelConfiguration): + let onDemandOption = onDemandViewModel.toOnDemandOption() + if let tunnel = tunnel { + // We're modifying an existing tunnel + tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] error in + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + } else { + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } else { + // We're adding a new tunnel + tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] result in + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: self) + case .success(let tunnel): + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } + } + } + + @objc func cancelTapped() { + dismiss(animated: true, completion: nil) + delegate?.tunnelEditingCancelled() + } +} + +// MARK: UITableViewDataSource + +extension TunnelEditTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .interface: + return interfaceFieldsBySection[section].count + case .peer(let peerData): + let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs } + return peerFieldsToShow.count + case .addPeer: + return 1 + case .onDemand: + if onDemandViewModel.isWiFiInterfaceEnabled { + return 3 + } else { + return 2 + } + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .interface: + return section == 0 ? tr("tunnelSectionTitleInterface") : nil + case .peer: + return tr("tunnelSectionTitlePeer") + case .addPeer: + return nil + case .onDemand: + return tr("tunnelSectionTitleOnDemand") + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .interface: + return interfaceFieldCell(for: tableView, at: indexPath) + case .peer(let peerData): + return peerCell(for: tableView, at: indexPath, with: peerData) + case .addPeer: + return addPeerCell(for: tableView, at: indexPath) + case .onDemand: + return onDemandCell(for: tableView, at: indexPath) + } + } + + private func interfaceFieldCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = interfaceFieldsBySection[indexPath.section][indexPath.row] + switch field { + case .generateKeyPair: + return generateKeyPairCell(for: tableView, at: indexPath, with: field) + case .publicKey: + return publicKeyCell(for: tableView, at: indexPath, with: field) + default: + return interfaceFieldKeyValueCell(for: tableView, at: indexPath, with: field) + } + } + + private func generateKeyPairCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = PrivateKey().base64Key + if let privateKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .privateKey), + let publicKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) { + let privateKeyIndex = IndexPath(row: privateKeyRow, section: indexPath.section) + let publicKeyIndex = IndexPath(row: publicKeyRow, section: indexPath.section) + self.tableView.reloadRows(at: [privateKeyIndex, publicKeyIndex], with: .fade) + } + } + return cell + } + + private func publicKeyCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: TunnelEditKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + + switch field { + case .name, .privateKey: + cell.placeholderText = tr("tunnelEditPlaceholderTextRequired") + cell.keyboardType = .default + case .addresses: + cell.placeholderText = tr("tunnelEditPlaceholderTextStronglyRecommended") + cell.keyboardType = .numbersAndPunctuation + case .dns: + cell.placeholderText = tunnelViewModel.peersData.contains(where: { $0.shouldStronglyRecommendDNS }) ? tr("tunnelEditPlaceholderTextStronglyRecommended") : tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .numbersAndPunctuation + case .listenPort, .mtu: + cell.placeholderText = tr("tunnelEditPlaceholderTextAutomatic") + cell.keyboardType = .numberPad + case .publicKey, .generateKeyPair: + cell.keyboardType = .default + case .status, .toggleStatus: + fatalError("Unexpected interface field") + } + + cell.isValueValid = (!tunnelViewModel.interfaceData.fieldsWithError.contains(field)) + // Bind values to view model + cell.value = tunnelViewModel.interfaceData[field] + if field == .dns { // While editing DNS, you might directly set exclude private IPs + cell.onValueBeingEdited = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + cell.onValueChanged = { [weak self] oldValue, newValue in + guard let self = self else { return } + let isAllowedIPsChanged = self.tunnelViewModel.updateDNSServersInAllowedIPsIfRequired(oldDNSServers: oldValue, newDNSServers: newValue) + if isAllowedIPsChanged { + let section = self.sections.firstIndex { if case .peer = $0 { return true } else { return false } } + if let section = section, let row = self.peerFields.firstIndex(of: .allowedIPs) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none) + } + } + } + } else { + cell.onValueChanged = { [weak self] _, value in + self?.tunnelViewModel.interfaceData[field] = value + } + } + // Compute public key live + if field == .privateKey { + cell.onValueBeingEdited = { [weak self] value in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = value + if let row = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: indexPath.section)], with: .none) + } + } + } + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData) -> UITableViewCell { + let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs } + let field = peerFieldsToShow[indexPath.row] + + switch field { + case .deletePeer: + return deletePeerCell(for: tableView, at: indexPath, peerData: peerData, field: field) + case .excludePrivateIPs: + return excludePrivateIPsCell(for: tableView, at: indexPath, peerData: peerData, field: field) + default: + return peerFieldKeyValueCell(for: tableView, at: indexPath, peerData: peerData, field: field) + } + } + + private func deletePeerCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.hasDestructiveAction = true + cell.onTapped = { [weak self, weak peerData] in + guard let self = self, let peerData = peerData else { return } + ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"), + buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"), + from: cell, presentingVC: self) { [weak self] in + guard let self = self else { return } + let removedSectionIndices = self.deletePeer(peer: peerData) + let shouldShowExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) + + // swiftlint:disable:next trailing_closure + tableView.performBatchUpdates({ + self.tableView.deleteSections(removedSectionIndices, with: .fade) + if shouldShowExcludePrivateIPs { + if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */) + self.tableView.insertRows(at: [rowIndexPath], with: .fade) + } + } + }) + } + } + return cell + } + + private func excludePrivateIPsCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isEnabled = peerData.shouldAllowExcludePrivateIPsControl + cell.isOn = peerData.excludePrivateIPsValue + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + peerData.excludePrivateIPsValueChanged(isOn: isOn, dnsServers: self.tunnelViewModel.interfaceData[.dns]) + if let row = self.peerFields.firstIndex(of: .allowedIPs) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: indexPath.section)], with: .none) + } + } + return cell + } + + private func peerFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell { + let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + + switch field { + case .publicKey: + cell.placeholderText = tr("tunnelEditPlaceholderTextRequired") + cell.keyboardType = .default + case .preSharedKey, .endpoint: + cell.placeholderText = tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .default + case .allowedIPs: + cell.placeholderText = tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .numbersAndPunctuation + case .persistentKeepAlive: + cell.placeholderText = tr("tunnelEditPlaceholderTextOff") + cell.keyboardType = .numberPad + case .excludePrivateIPs, .deletePeer: + cell.keyboardType = .default + case .rxBytes, .txBytes, .lastHandshakeTime: + fatalError() + } + + cell.isValueValid = !peerData.fieldsWithError.contains(field) + cell.value = peerData[field] + + if field == .allowedIPs { + let firstInterfaceSection = sections.firstIndex { $0 == .interface }! + let interfaceSubSection = interfaceFieldsBySection.firstIndex { $0.contains(.dns) }! + let dnsRow = interfaceFieldsBySection[interfaceSubSection].firstIndex { $0 == .dns }! + + cell.onValueBeingEdited = { [weak self, weak peerData] value in + guard let self = self, let peerData = peerData else { return } + + let oldValue = peerData.shouldAllowExcludePrivateIPsControl + peerData[.allowedIPs] = value + if oldValue != peerData.shouldAllowExcludePrivateIPsControl, let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + if peerData.shouldAllowExcludePrivateIPsControl { + self.tableView.insertRows(at: [IndexPath(row: row, section: indexPath.section)], with: .fade) + } else { + self.tableView.deleteRows(at: [IndexPath(row: row, section: indexPath.section)], with: .fade) + } + } + + tableView.reloadRows(at: [IndexPath(row: dnsRow, section: firstInterfaceSection + interfaceSubSection)], with: .none) + } + } else { + cell.onValueChanged = { [weak peerData] _, value in + peerData?[field] = value + } + } + + return cell + } + + private func addPeerCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = tr("addPeerButtonTitle") + cell.onTapped = { [weak self] in + guard let self = self else { return } + let shouldHideExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) + let addedSectionIndices = self.appendEmptyPeer() + tableView.performBatchUpdates({ + tableView.insertSections(addedSectionIndices, with: .fade) + if shouldHideExcludePrivateIPs { + if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */) + self.tableView.deleteRows(at: [rowIndexPath], with: .fade) + } + } + }, completion: nil) + } + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = onDemandFields[indexPath.row] + if indexPath.row < 2 { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isOn = onDemandViewModel.isEnabled(field: field) + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + self.onDemandViewModel.setEnabled(field: field, isEnabled: isOn) + let section = self.sections.firstIndex { $0 == .onDemand }! + let indexPath = IndexPath(row: 2, section: section) + if field == .wiFiInterface { + if isOn { + tableView.insertRows(at: [indexPath], with: .fade) + } else { + tableView.deleteRows(at: [indexPath], with: .fade) + } + } + } + return cell + } else { + let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.detailMessage = onDemandViewModel.localizedSSIDDescription + return cell + } + } + + func appendEmptyPeer() -> IndexSet { + tunnelViewModel.appendEmptyPeer() + loadSections() + let addedPeerIndex = tunnelViewModel.peersData.count - 1 + return IndexSet(integer: interfaceFieldsBySection.count + addedPeerIndex) + } + + func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet { + tunnelViewModel.deletePeer(peer: peer) + loadSections() + return IndexSet(integer: interfaceFieldsBySection.count + peer.index) + } +} + +extension TunnelEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], indexPath.row == 2 { + return indexPath + } else { + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .onDemand: + assert(indexPath.row == 2) + tableView.deselectRow(at: indexPath, animated: true) + let ssidOptionVC = SSIDOptionEditTableViewController(option: onDemandViewModel.ssidOption, ssids: onDemandViewModel.selectedSSIDs) + ssidOptionVC.delegate = self + navigationController?.pushViewController(ssidOptionVC, animated: true) + default: + assertionFailure() + } + } +} + +extension TunnelEditTableViewController: SSIDOptionEditTableViewControllerDelegate { + func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) { + onDemandViewModel.selectedSSIDs = ssids + onDemandViewModel.ssidOption = option + onDemandViewModel.fixSSIDOption() + if let onDemandSection = sections.firstIndex(where: { $0 == .onDemand }) { + if let ssidRowIndex = onDemandFields.firstIndex(of: .ssid) { + let indexPath = IndexPath(row: ssidRowIndex, section: onDemandSection) + tableView.reloadRows(at: [indexPath], with: .none) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift new file mode 100644 index 0000000..4486151 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import MobileCoreServices +import UserNotifications + +class TunnelsListTableViewController: UIViewController { + + var tunnelsManager: TunnelsManager? + + enum TableState: Equatable { + case normal + case rowSwiped + case multiSelect(selectionCount: Int) + } + + let tableView: UITableView = { + let tableView = UITableView(frame: CGRect.zero, style: .plain) + tableView.estimatedRowHeight = 60 + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.register(TunnelListCell.self) + return tableView + }() + + let centeredAddButton: BorderedTextButton = { + let button = BorderedTextButton() + button.title = tr("tunnelsListCenteredAddTunnelButtonTitle") + button.isHidden = true + return button + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator: UIActivityIndicatorView + busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + var detailDisplayedTunnel: TunnelContainer? + var tableState: TableState = .normal { + didSet { + handleTableStateChange() + } + } + + override func loadView() { + view = UIView() + view.backgroundColor = .systemBackground + + tableView.dataSource = self + tableView.delegate = self + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + view.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + view.addSubview(centeredAddButton) + centeredAddButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + centeredAddButton.onTapped = { [weak self] in + guard let self = self else { return } + self.addButtonTapped(sender: self.centeredAddButton) + } + + busyIndicator.startAnimating() + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableState = .normal + restorationIdentifier = "TunnelsListVC" + } + + func handleTableStateChange() { + switch tableState { + case .normal: + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:))) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:))) + case .rowSwiped: + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectButtonTitle"), style: .plain, target: self, action: #selector(selectButtonTapped)) + case .multiSelect(let selectionCount): + if selectionCount > 0 { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListDeleteButtonTitle"), style: .plain, target: self, action: #selector(deleteButtonTapped(sender:))) + } else { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectAllButtonTitle"), style: .plain, target: self, action: #selector(selectAllButtonTapped)) + } + } + if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 { + navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount) + } else { + navigationItem.title = tr("tunnelsListTitle") + } + if case .multiSelect = tableState { + tableView.allowsMultipleSelectionDuringEditing = true + } else { + tableView.allowsMultipleSelectionDuringEditing = false + } + } + + func setTunnelsManager(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + tunnelsManager.tunnelsListDelegate = self + + busyIndicator.stopAnimating() + tableView.reloadData() + centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0 + } + + override func viewWillAppear(_: Bool) { + if let selectedRowIndexPath = tableView.indexPathForSelectedRow { + tableView.deselectRow(at: selectedRowIndexPath, animated: false) + } + } + + @objc func addButtonTapped(sender: AnyObject) { + guard tunnelsManager != nil else { return } + + let alert = UIAlertController(title: "", message: tr("addTunnelMenuHeader"), preferredStyle: .actionSheet) + let importFileAction = UIAlertAction(title: tr("addTunnelMenuImportFile"), style: .default) { [weak self] _ in + self?.presentViewControllerForFileImport() + } + alert.addAction(importFileAction) + + let scanQRCodeAction = UIAlertAction(title: tr("addTunnelMenuQRCode"), style: .default) { [weak self] _ in + self?.presentViewControllerForScanningQRCode() + } + alert.addAction(scanQRCodeAction) + + let createFromScratchAction = UIAlertAction(title: tr("addTunnelMenuFromScratch"), style: .default) { [weak self] _ in + if let self = self, let tunnelsManager = self.tunnelsManager { + self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager) + } + } + alert.addAction(createFromScratchAction) + + let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel) + alert.addAction(cancelAction) + + if let sender = sender as? UIBarButtonItem { + alert.popoverPresentationController?.barButtonItem = sender + } else if let sender = sender as? UIView { + alert.popoverPresentationController?.sourceView = sender + alert.popoverPresentationController?.sourceRect = sender.bounds + } + present(alert, animated: true, completion: nil) + } + + @objc func settingsButtonTapped(sender: UIBarButtonItem) { + guard tunnelsManager != nil else { return } + + let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager) + let settingsNC = UINavigationController(rootViewController: settingsVC) + settingsNC.modalPresentationStyle = .formSheet + present(settingsNC, animated: true) + } + + func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager) { + let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager) + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .fullScreen + present(editNC, animated: true) + } + + func presentViewControllerForFileImport() { + let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)] + let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + filePicker.delegate = self + present(filePicker, animated: true) + } + + func presentViewControllerForScanningQRCode() { + let scanQRCodeVC = QRScanViewController() + scanQRCodeVC.delegate = self + let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC) + scanQRCodeNC.modalPresentationStyle = .fullScreen + present(scanQRCodeNC, animated: true) + } + + @objc func selectButtonTapped() { + let shouldCancelSwipe = tableState == .rowSwiped + tableState = .multiSelect(selectionCount: 0) + if shouldCancelSwipe { + tableView.setEditing(false, animated: false) + } + tableView.setEditing(true, animated: true) + } + + @objc func doneButtonTapped() { + tableState = .normal + tableView.setEditing(false, animated: true) + } + + @objc func selectAllButtonTapped() { + guard tableView.isEditing else { return } + guard let tunnelsManager = tunnelsManager else { return } + for index in 0 ..< tunnelsManager.numberOfTunnels() { + tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none) + } + tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0) + } + + @objc func cancelButtonTapped() { + tableState = .normal + tableView.setEditing(false, animated: true) + } + + @objc func deleteButtonTapped(sender: AnyObject?) { + guard let sender = sender as? UIBarButtonItem else { return } + guard let tunnelsManager = tunnelsManager else { return } + + let selectedTunnelIndices = tableView.indexPathsForSelectedRows?.map { $0.row } ?? [] + let selectedTunnels = selectedTunnelIndices.compactMap { tunnelIndex in + tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() ? tunnelsManager.tunnel(at: tunnelIndex) : nil + } + guard !selectedTunnels.isEmpty else { return } + let message = selectedTunnels.count == 1 ? + tr(format: "deleteTunnelConfirmationAlertButtonMessage (%d)", selectedTunnels.count) : + tr(format: "deleteTunnelsConfirmationAlertButtonMessage (%d)", selectedTunnels.count) + let title = tr("deleteTunnelsConfirmationAlertButtonTitle") + ConfirmationAlertPresenter.showConfirmationAlert(message: message, buttonTitle: title, + from: sender, presentingVC: self) { [weak self] in + self?.tunnelsManager?.removeMultiple(tunnels: selectedTunnels) { [weak self] error in + guard let self = self else { return } + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + self.tableState = .normal + self.tableView.setEditing(false, animated: true) + } + } + } + + func showTunnelDetail(for tunnel: TunnelContainer, animated: Bool) { + guard let tunnelsManager = tunnelsManager else { return } + guard let splitViewController = splitViewController else { return } + guard let navController = navigationController else { return } + + let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, + tunnel: tunnel) + let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) + tunnelDetailNC.restorationIdentifier = "DetailNC" + if splitViewController.isCollapsed && navController.viewControllers.count > 1 { + navController.setViewControllers([self, tunnelDetailNC], animated: animated) + } else { + splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated) + } + detailDisplayedTunnel = tunnel + self.presentedViewController?.dismiss(animated: false, completion: nil) + } +} + +extension TunnelsListTableViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let tunnelsManager = tunnelsManager else { return } + TunnelImporter.importFromFile(urls: urls, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) + } +} + +extension TunnelsListTableViewController: QRScanViewControllerDelegate { + func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, + completionHandler: (() -> Void)?) { + tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler) + case .success: + completionHandler?() + } + } + } +} + +extension TunnelsListTableViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return (tunnelsManager?.numberOfTunnels() ?? 0) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TunnelListCell = tableView.dequeueReusableCell(for: indexPath) + if let tunnelsManager = tunnelsManager { + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + cell.tunnel = tunnel + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self, let tunnelsManager = self.tunnelsManager else { return } + if tunnel.hasOnDemandRules { + tunnelsManager.setOnDemandEnabled(isOn, on: tunnel) { error in + if error == nil && !isOn { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } else { + if isOn { + tunnelsManager.startActivation(of: tunnel) + } else { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } + } + return cell + } +} + +extension TunnelsListTableViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard !tableView.isEditing else { + tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0) + return + } + guard let tunnelsManager = tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + showTunnelDetail(for: tunnel, animated: true) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard !tableView.isEditing else { + tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0) + return + } + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: tr("tunnelsListSwipeDeleteButtonTitle")) { [weak self] _, _, completionHandler in + guard let tunnelsManager = self?.tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + tunnelsManager.remove(tunnel: tunnel) { error in + if error != nil { + ErrorPresenter.showErrorAlert(error: error!, from: self) + completionHandler(false) + } else { + completionHandler(true) + } + } + } + return UISwipeActionsConfiguration(actions: [deleteAction]) + } + + func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { + if tableState == .normal { + tableState = .rowSwiped + } + } + + func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { + if tableState == .rowSwiped { + tableState = .normal + } + } +} + +extension TunnelsListTableViewController: TunnelsManagerListDelegate { + func tunnelAdded(at index: Int) { + tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0) + } + + func tunnelModified(at index: Int) { + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + } + + func tunnelMoved(from oldIndex: Int, to newIndex: Int) { + tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0)) + } + + func tunnelRemoved(at index: Int, tunnel: TunnelContainer) { + tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0 + if detailDisplayedTunnel == tunnel, let splitViewController = splitViewController { + if splitViewController.isCollapsed != false { + (splitViewController.viewControllers[0] as? UINavigationController)?.popToRootViewController(animated: false) + } else { + let detailVC = UIViewController() + detailVC.view.backgroundColor = .systemBackground + let detailNC = UINavigationController(rootViewController: detailVC) + splitViewController.showDetailViewController(detailNC, sender: self) + } + detailDisplayedTunnel = nil + if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController { + self.presentedViewController?.dismiss(animated: false, completion: nil) + } + } + } +} + +extension UISplitViewController { + func showDetailViewController(_ viewController: UIViewController, sender: Any?, animated: Bool) { + if animated { + showDetailViewController(viewController, sender: sender) + } else { + UIView.performWithoutAnimation { + showDetailViewController(viewController, sender: sender) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/WireGuard.entitlements b/Sources/WireGuardApp/UI/iOS/WireGuard.entitlements new file mode 100644 index 0000000..93c7249 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/WireGuard.entitlements @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.developer.networking.networkextension</key> + <array> + <string>packet-tunnel-provider</string> + </array> + <key>com.apple.developer.networking.wifi-info</key> + <true/> + <key>com.apple.security.application-groups</key> + <array> + <string>group.$(APP_ID_IOS)</string> + </array> +</dict> +</plist> |