aboutsummaryrefslogtreecommitdiffstats
path: root/Sources/WireGuardApp/UI/iOS
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/WireGuardApp/UI/iOS')
-rw-r--r--Sources/WireGuardApp/UI/iOS/AppDelegate.swift84
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json116
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.pngbin0 -> 81773 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.pngbin0 -> 865 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.pngbin0 -> 1508 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.pngbin0 -> 1508 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.pngbin0 -> 3133 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.pngbin0 -> 1166 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.pngbin0 -> 3012 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.pngbin0 -> 3012 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.pngbin0 -> 4615 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.pngbin0 -> 1508 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.pngbin0 -> 4188 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.pngbin0 -> 4188 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.pngbin0 -> 6518 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.pngbin0 -> 6518 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.pngbin0 -> 10037 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.pngbin0 -> 3953 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.pngbin0 -> 8347 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.pngbin0 -> 9329 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json6
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json15
-rw-r--r--Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdfbin0 -> 8895 bytes
-rw-r--r--Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard82
-rw-r--r--Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift25
-rw-r--r--Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift19
-rw-r--r--Sources/WireGuardApp/UI/iOS/Info.plist131
-rw-r--r--Sources/WireGuardApp/UI/iOS/QuickActionItem.swift25
-rw-r--r--Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift72
-rw-r--r--Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift21
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift51
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift55
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift31
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift30
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift71
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift225
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift56
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/TextCell.swift34
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift48
-rw-r--r--Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift162
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift147
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift142
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift155
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift49
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift307
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift173
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift496
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift503
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift423
-rw-r--r--Sources/WireGuardApp/UI/iOS/WireGuard.entitlements16
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
new file mode 100644
index 0000000..ec74f4d
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png
Binary files differ
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
new file mode 100644
index 0000000..e0dc54d
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png
Binary files differ
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
new file mode 100644
index 0000000..429297a
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png
Binary files differ
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
new file mode 100644
index 0000000..429297a
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png
Binary files differ
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
new file mode 100644
index 0000000..eb79183
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png
Binary files differ
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
new file mode 100644
index 0000000..a8fd5c2
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png
Binary files differ
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
new file mode 100644
index 0000000..3645f0e
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png
Binary files differ
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
new file mode 100644
index 0000000..3645f0e
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png
Binary files differ
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
new file mode 100644
index 0000000..386bb88
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png
Binary files differ
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
new file mode 100644
index 0000000..429297a
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png
Binary files differ
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
new file mode 100644
index 0000000..49bfff8
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png
Binary files differ
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
new file mode 100644
index 0000000..49bfff8
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png
Binary files differ
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
new file mode 100644
index 0000000..8aa7b6c
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png
Binary files differ
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
new file mode 100644
index 0000000..8aa7b6c
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png
Binary files differ
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
new file mode 100644
index 0000000..f6c3318
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png
Binary files differ
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
new file mode 100644
index 0000000..b0e2c3c
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png
Binary files differ
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
new file mode 100644
index 0000000..0c708bc
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png
Binary files differ
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
new file mode 100644
index 0000000..b7338c4
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png
Binary files differ
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
new file mode 100644
index 0000000..69469c5
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf
Binary files differ
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>