aboutsummaryrefslogtreecommitdiffstats
path: root/Sources/WireGuardApp/UI/macOS
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2020-12-02 12:27:39 +0100
committerAndrej Mihajlov <and@mullvad.net>2020-12-03 13:32:24 +0100
commitec574085703ea1c8b2d4538596961beb910c4382 (patch)
tree73cf8bbdb74fe5575606664bccd0232ffa911803 /Sources/WireGuardApp/UI/macOS
parentWireGuardKit: Assert that resolutionResults must not contain failures (diff)
downloadwireguard-apple-ec574085703ea1c8b2d4538596961beb910c4382.tar.xz
wireguard-apple-ec574085703ea1c8b2d4538596961beb910c4382.zip
Move all source files to `Sources/` and rename WireGuardKit targets
Signed-off-by: Andrej Mihajlov <and@mullvad.net>
Diffstat (limited to 'Sources/WireGuardApp/UI/macOS')
-rw-r--r--Sources/WireGuardApp/UI/macOS/AppDelegate.swift252
-rw-r--r--Sources/WireGuardApp/UI/macOS/Application.swift19
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json68
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.pngbin0 -> 90032 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.pngbin0 -> 10281 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.pngbin0 -> 1161 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.pngbin0 -> 22477 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.pngbin0 -> 22477 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.pngbin0 -> 2385 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.pngbin0 -> 2385 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.pngbin0 -> 50192 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.pngbin0 -> 50192 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.pngbin0 -> 4991 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/Contents.json6
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json26
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.pngbin0 -> 978 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.pngbin0 -> 1722 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.pngbin0 -> 1975 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json26
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.pngbin0 -> 881 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.pngbin0 -> 1390 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.pngbin0 -> 1581 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json26
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.pngbin0 -> 953 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.pngbin0 -> 1570 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.pngbin0 -> 1744 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json26
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.pngbin0 -> 942 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.pngbin0 -> 1502 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.pngbin0 -> 1676 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json26
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.pngbin0 -> 958 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.pngbin0 -> 1521 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.pngbin0 -> 1677 bytes
-rw-r--r--Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift23
-rw-r--r--Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift19
-rw-r--r--Sources/WireGuardApp/UI/macOS/Info.plist40
-rw-r--r--Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift28
-rw-r--r--Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist36
-rw-r--r--Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements8
-rw-r--r--Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m17
-rw-r--r--Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift35
-rw-r--r--Sources/WireGuardApp/UI/macOS/MainMenu.swift130
-rw-r--r--Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift25
-rw-r--r--Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift17
-rw-r--r--Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift57
-rw-r--r--Sources/WireGuardApp/UI/macOS/StatusItemController.swift64
-rw-r--r--Sources/WireGuardApp/UI/macOS/StatusMenu.swift234
-rw-r--r--Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift119
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift67
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift47
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift180
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift89
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift36
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift139
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift66
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift171
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift75
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/highlighter.c641
-rw-r--r--Sources/WireGuardApp/UI/macOS/View/highlighter.h38
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift54
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift274
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift135
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift516
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift297
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift354
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift71
-rw-r--r--Sources/WireGuardApp/UI/macOS/WireGuard.entitlements18
68 files changed, 4595 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/macOS/AppDelegate.swift b/Sources/WireGuardApp/UI/macOS/AppDelegate.swift
new file mode 100644
index 0000000..6e3783a
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/AppDelegate.swift
@@ -0,0 +1,252 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import ServiceManagement
+import WireGuardKit
+
+@NSApplicationMain
+class AppDelegate: NSObject, NSApplicationDelegate {
+
+ var tunnelsManager: TunnelsManager?
+ var tunnelsTracker: TunnelsTracker?
+ var statusItemController: StatusItemController?
+
+ var manageTunnelsRootVC: ManageTunnelsRootViewController?
+ var manageTunnelsWindowObject: NSWindow?
+ var onAppDeactivation: (() -> Void)?
+
+ func applicationWillFinishLaunching(_ notification: Notification) {
+ // To workaround a possible AppKit bug that causes the main menu to become unresponsive sometimes
+ // (especially when launched through Xcode) if we call setActivationPolicy(.regular) in
+ // in applicationDidFinishLaunching, we set it to .prohibited here.
+ // Setting it to .regular would fix that problem too, but at this point, we don't know
+ // whether the app was launched at login or not, so we're not sure whether we should
+ // show the app icon in the dock or not.
+ NSApp.setActivationPolicy(.prohibited)
+ }
+
+ func applicationDidFinishLaunching(_ aNotification: Notification) {
+ Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path)
+ registerLoginItem(shouldLaunchAtLogin: true)
+
+ var isLaunchedAtLogin = false
+ if let appleEvent = NSAppleEventManager.shared().currentAppleEvent {
+ isLaunchedAtLogin = LaunchedAtLoginDetector.isLaunchedAtLogin(openAppleEvent: appleEvent)
+ }
+
+ NSApp.mainMenu = MainMenu()
+ setDockIconAndMainMenuVisibility(isVisible: !isLaunchedAtLogin)
+
+ TunnelsManager.create { [weak self] result in
+ guard let self = self else { return }
+
+ switch result {
+ case .failure(let error):
+ ErrorPresenter.showErrorAlert(error: error, from: nil)
+ case .success(let tunnelsManager):
+ let statusMenu = StatusMenu(tunnelsManager: tunnelsManager)
+ statusMenu.windowDelegate = self
+
+ let statusItemController = StatusItemController()
+ statusItemController.statusItem.menu = statusMenu
+
+ let tunnelsTracker = TunnelsTracker(tunnelsManager: tunnelsManager)
+ tunnelsTracker.statusMenu = statusMenu
+ tunnelsTracker.statusItemController = statusItemController
+
+ self.tunnelsManager = tunnelsManager
+ self.tunnelsTracker = tunnelsTracker
+ self.statusItemController = statusItemController
+
+ if !isLaunchedAtLogin {
+ self.showManageTunnelsWindow(completion: nil)
+ }
+ }
+ }
+ }
+
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
+ if let appleEvent = NSAppleEventManager.shared().currentAppleEvent {
+ if LaunchedAtLoginDetector.isReopenedByLoginItemHelper(reopenAppleEvent: appleEvent) {
+ return false
+ }
+ }
+ if hasVisibleWindows {
+ return true
+ }
+ showManageTunnelsWindow(completion: nil)
+ return false
+ }
+
+ @objc func confirmAndQuit() {
+ let alert = NSAlert()
+ alert.messageText = tr("macConfirmAndQuitAlertMessage")
+ if let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating {
+ alert.informativeText = tr(format: "macConfirmAndQuitInfoWithActiveTunnel (%@)", currentTunnel.name)
+ } else {
+ alert.informativeText = tr("macConfirmAndQuitAlertInfo")
+ }
+ alert.addButton(withTitle: tr("macConfirmAndQuitAlertCloseWindow"))
+ alert.addButton(withTitle: tr("macConfirmAndQuitAlertQuitWireGuard"))
+
+ NSApp.activate(ignoringOtherApps: true)
+ if let manageWindow = manageTunnelsWindowObject {
+ manageWindow.orderFront(self)
+ alert.beginSheetModal(for: manageWindow) { response in
+ switch response {
+ case .alertFirstButtonReturn:
+ manageWindow.close()
+ case .alertSecondButtonReturn:
+ NSApp.terminate(nil)
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ @objc func quit() {
+ if let manageWindow = manageTunnelsWindowObject, manageWindow.attachedSheet != nil {
+ NSApp.activate(ignoringOtherApps: true)
+ manageWindow.orderFront(self)
+ return
+ }
+ registerLoginItem(shouldLaunchAtLogin: false)
+ guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
+ NSApp.terminate(nil)
+ return
+ }
+ let alert = NSAlert()
+ alert.messageText = tr("macAppExitingWithActiveTunnelMessage")
+ alert.informativeText = tr("macAppExitingWithActiveTunnelInfo")
+ NSApp.activate(ignoringOtherApps: true)
+ if let manageWindow = manageTunnelsWindowObject {
+ manageWindow.orderFront(self)
+ alert.beginSheetModal(for: manageWindow) { _ in
+ NSApp.terminate(nil)
+ }
+ } else {
+ alert.runModal()
+ NSApp.terminate(nil)
+ }
+ }
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
+ return .terminateNow
+ }
+ guard let appleEvent = NSAppleEventManager.shared().currentAppleEvent else {
+ return .terminateNow
+ }
+ guard MacAppStoreUpdateDetector.isUpdatingFromMacAppStore(quitAppleEvent: appleEvent) else {
+ return .terminateNow
+ }
+ let alert = NSAlert()
+ alert.messageText = tr("macAppStoreUpdatingAlertMessage")
+ if currentTunnel.isActivateOnDemandEnabled {
+ alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithOnDemand (%@)", currentTunnel.name)
+ } else {
+ alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithoutOnDemand (%@)", currentTunnel.name)
+ }
+ NSApp.activate(ignoringOtherApps: true)
+ if let manageWindow = manageTunnelsWindowObject {
+ alert.beginSheetModal(for: manageWindow) { _ in }
+ } else {
+ alert.runModal()
+ }
+ return .terminateCancel
+ }
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ application: NSApplication) -> Bool {
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
+ self?.setDockIconAndMainMenuVisibility(isVisible: false)
+ }
+ return false
+ }
+
+ private func setDockIconAndMainMenuVisibility(isVisible: Bool, completion: (() -> Void)? = nil) {
+ let currentActivationPolicy = NSApp.activationPolicy()
+ let newActivationPolicy: NSApplication.ActivationPolicy = isVisible ? .regular : .accessory
+ guard currentActivationPolicy != newActivationPolicy else {
+ if newActivationPolicy == .regular {
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ completion?()
+ return
+ }
+ if newActivationPolicy == .regular && NSApp.isActive {
+ // To workaround a possible AppKit bug that causes the main menu to become unresponsive,
+ // we should deactivate the app first and then set the activation policy.
+ // NSApp.deactivate() doesn't always deactivate the app, so we instead use
+ // setActivationPolicy(.prohibited).
+ onAppDeactivation = {
+ NSApp.setActivationPolicy(.regular)
+ NSApp.activate(ignoringOtherApps: true)
+ completion?()
+ }
+ NSApp.setActivationPolicy(.prohibited)
+ } else {
+ NSApp.setActivationPolicy(newActivationPolicy)
+ if newActivationPolicy == .regular {
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ completion?()
+ }
+ }
+
+ func applicationDidResignActive(_ notification: Notification) {
+ onAppDeactivation?()
+ onAppDeactivation = nil
+ }
+}
+
+extension AppDelegate {
+ @objc func aboutClicked() {
+ var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
+ if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
+ appVersion += " (\(appBuild))"
+ }
+ let appVersionString = [
+ tr(format: "macAppVersion (%@)", appVersion),
+ tr(format: "macGoBackendVersion (%@)", wireGuardVersion)
+ ].joined(separator: "\n")
+ let donateString = NSMutableAttributedString(string: tr("donateLink"))
+ donateString.addAttribute(.link, value: "https://www.wireguard.com/donations/", range: NSRange(location: 0, length: donateString.length))
+ NSApp.activate(ignoringOtherApps: true)
+ NSApp.orderFrontStandardAboutPanel(options: [
+ .applicationVersion: appVersionString,
+ .version: "",
+ .credits: donateString
+ ])
+ }
+}
+
+extension AppDelegate: StatusMenuWindowDelegate {
+ func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?) {
+ guard let tunnelsManager = tunnelsManager else {
+ completion?(nil)
+ return
+ }
+ if manageTunnelsWindowObject == nil {
+ manageTunnelsRootVC = ManageTunnelsRootViewController(tunnelsManager: tunnelsManager)
+ let window = NSWindow(contentViewController: manageTunnelsRootVC!)
+ window.title = tr("macWindowTitleManageTunnels")
+ window.setContentSize(NSSize(width: 800, height: 480))
+ window.setFrameAutosaveName(NSWindow.FrameAutosaveName("ManageTunnelsWindow")) // Auto-save window position and size
+ manageTunnelsWindowObject = window
+ tunnelsTracker?.manageTunnelsRootVC = manageTunnelsRootVC
+ }
+ setDockIconAndMainMenuVisibility(isVisible: true) { [weak manageTunnelsWindowObject] in
+ manageTunnelsWindowObject?.makeKeyAndOrderFront(self)
+ completion?(manageTunnelsWindowObject)
+ }
+ }
+}
+
+@discardableResult
+func registerLoginItem(shouldLaunchAtLogin: Bool) -> Bool {
+ let appId = Bundle.main.bundleIdentifier!
+ let helperBundleId = "\(appId).login-item-helper"
+ return SMLoginItemSetEnabled(helperBundleId as CFString, shouldLaunchAtLogin)
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Application.swift b/Sources/WireGuardApp/UI/macOS/Application.swift
new file mode 100644
index 0000000..0ce274a
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Application.swift
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class Application: NSApplication {
+
+ private var appDelegate: AppDelegate? //swiftlint:disable:this weak_delegate
+
+ override init() {
+ super.init()
+ appDelegate = AppDelegate() // Keep a strong reference to the app delegate
+ delegate = appDelegate // Set delegate before app.run() gets called in NSApplicationMain()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..32ea528
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon16.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon32.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon32-1.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon64.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon128.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon256.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon256-1.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon512.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon512-1.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "WireGuardMacAppIcon.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+} \ No newline at end of file
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png
new file mode 100644
index 0000000..83c4701
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png
new file mode 100644
index 0000000..16f9056
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png
new file mode 100644
index 0000000..e05c706
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png
new file mode 100644
index 0000000..ac09bdd
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png
new file mode 100644
index 0000000..ac09bdd
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png
new file mode 100644
index 0000000..edf0ca5
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png
new file mode 100644
index 0000000..edf0ca5
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png
new file mode 100644
index 0000000..54b28ff
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png
new file mode 100644
index 0000000..54b28ff
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png
new file mode 100644
index 0000000..98441a8
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..da4a164
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/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/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json
new file mode 100644
index 0000000..a8cd607
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json
@@ -0,0 +1,26 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIcon@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIcon@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIcon@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png
new file mode 100644
index 0000000..c0a43e7
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png
new file mode 100644
index 0000000..2057c31
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png
new file mode 100644
index 0000000..60cc363
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json
new file mode 100644
index 0000000..f9e2cd6
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json
@@ -0,0 +1,26 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDimmed@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDimmed@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDimmed@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png
new file mode 100644
index 0000000..fb9d8f7
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png
new file mode 100644
index 0000000..2f4e613
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png
new file mode 100644
index 0000000..cc5ead9
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json
new file mode 100644
index 0000000..15384b9
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json
@@ -0,0 +1,26 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot1@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot1@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot1@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png
new file mode 100644
index 0000000..bbbe0c3
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png
new file mode 100644
index 0000000..01b5eb3
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png
new file mode 100644
index 0000000..76afa15
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json
new file mode 100644
index 0000000..f728363
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json
@@ -0,0 +1,26 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot2@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot2@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot2@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png
new file mode 100644
index 0000000..a16143f
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png
new file mode 100644
index 0000000..ce00482
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png
new file mode 100644
index 0000000..82640f7
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json
new file mode 100644
index 0000000..eeaa5d1
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json
@@ -0,0 +1,26 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot3@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot3@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "StatusBarIconDot3@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png
new file mode 100644
index 0000000..80e221b
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png
new file mode 100644
index 0000000..663f92b
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png
new file mode 100644
index 0000000..10e43d9
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png
Binary files differ
diff --git a/Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift b/Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift
new file mode 100644
index 0000000..1eb2b04
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ErrorPresenter: ErrorPresenterProtocol {
+ static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) {
+ let alert = NSAlert()
+ alert.messageText = title
+ alert.informativeText = message
+ onPresented?()
+ if let sourceVC = sourceVC as? NSViewController {
+ NSApp.activate(ignoringOtherApps: true)
+ sourceVC.view.window!.makeKeyAndOrderFront(nil)
+ alert.beginSheetModal(for: sourceVC.view.window!) { _ in
+ onDismissal?()
+ }
+ } else {
+ alert.runModal()
+ onDismissal?()
+ }
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift b/Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift
new file mode 100644
index 0000000..d081f8c
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ImportPanelPresenter {
+ static func presentImportPanel(tunnelsManager: TunnelsManager, sourceVC: NSViewController?) {
+ guard let window = sourceVC?.view.window else { return }
+ let openPanel = NSOpenPanel()
+ openPanel.prompt = tr("macSheetButtonImport")
+ openPanel.allowedFileTypes = ["conf", "zip"]
+ openPanel.allowsMultipleSelection = true
+ openPanel.beginSheetModal(for: window) { [weak tunnelsManager] response in
+ guard let tunnelsManager = tunnelsManager else { return }
+ guard response == .OK else { return }
+ TunnelImporter.importFromFile(urls: openPanel.urls, into: tunnelsManager, sourceVC: sourceVC, errorPresenterType: ErrorPresenter.self)
+ }
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/Info.plist b/Sources/WireGuardApp/UI/macOS/Info.plist
new file mode 100644
index 0000000..db1afeb
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/Info.plist
@@ -0,0 +1,40 @@
+<?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>LSMultipleInstancesProhibited</key>
+ <true/>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIconFile</key>
+ <string></string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</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>LSMinimumSystemVersion</key>
+ <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
+ <key>LSUIElement</key>
+ <true/>
+ <key>NSHumanReadableCopyright</key>
+ <string>Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.</string>
+ <key>NSPrincipalClass</key>
+ <string>WireGuard.Application</string>
+ <key>LSApplicationCategoryType</key>
+ <string>public.app-category.utilities</string>
+ <key>com.wireguard.macos.app_group_id</key>
+ <string>$(DEVELOPMENT_TEAM).group.$(APP_ID_MACOS)</string>
+</dict>
+</plist>
diff --git a/Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift b/Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift
new file mode 100644
index 0000000..0d8e3d8
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class LaunchedAtLoginDetector {
+ static let launchCode = "LaunchedByWireGuardLoginItemHelper"
+
+ static func isLaunchedAtLogin(openAppleEvent: NSAppleEventDescriptor) -> Bool {
+ guard isOpenEvent(openAppleEvent) else { return false }
+ guard let propData = openAppleEvent.paramDescriptor(forKeyword: keyAEPropData) else { return false }
+ return propData.stringValue == launchCode
+ }
+
+ static func isReopenedByLoginItemHelper(reopenAppleEvent: NSAppleEventDescriptor) -> Bool {
+ guard isReopenEvent(reopenAppleEvent) else { return false }
+ guard let propData = reopenAppleEvent.paramDescriptor(forKeyword: keyAEPropData) else { return false }
+ return propData.stringValue == launchCode
+ }
+}
+
+private func isOpenEvent(_ event: NSAppleEventDescriptor) -> Bool {
+ return event.eventClass == kCoreEventClass && event.eventID == kAEOpenApplication
+}
+
+private func isReopenEvent(_ event: NSAppleEventDescriptor) -> Bool {
+ return event.eventClass == kCoreEventClass && event.eventID == kAEReopenApplication
+}
diff --git a/Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist
new file mode 100644
index 0000000..7ddff91
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist
@@ -0,0 +1,36 @@
+<?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>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIconFile</key>
+ <string></string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</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>LSMinimumSystemVersion</key>
+ <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
+ <key>NSHumanReadableCopyright</key>
+ <string>Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.</string>
+ <key>NSPrincipalClass</key>
+ <string>NSApplication</string>
+ <key>LSBackgroundOnly</key>
+ <true/>
+ <key>com.wireguard.macos.app_id</key>
+ <string>$(APP_ID_MACOS)</string>
+</dict>
+</plist>
diff --git a/Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements
new file mode 100644
index 0000000..852fa1a
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements
@@ -0,0 +1,8 @@
+<?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.security.app-sandbox</key>
+ <true/>
+</dict>
+</plist>
diff --git a/Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m
new file mode 100644
index 0000000..1010b49
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+#import <Cocoa/Cocoa.h>
+
+int main(int argc, char *argv[])
+{
+ NSString *appIdInfoDictionaryKey = @"com.wireguard.macos.app_id";
+ NSString *appId = [NSBundle.mainBundle objectForInfoDictionaryKey:appIdInfoDictionaryKey];
+
+ NSString *launchCode = @"LaunchedByWireGuardLoginItemHelper";
+ NSAppleEventDescriptor *paramDescriptor = [NSAppleEventDescriptor descriptorWithString:launchCode];
+
+ [NSWorkspace.sharedWorkspace launchAppWithBundleIdentifier:appId options:NSWorkspaceLaunchWithoutActivation
+ additionalEventParamDescriptor:paramDescriptor launchIdentifier:NULL];
+ return 0;
+}
diff --git a/Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift b/Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift
new file mode 100644
index 0000000..68608ca
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class MacAppStoreUpdateDetector {
+ static func isUpdatingFromMacAppStore(quitAppleEvent: NSAppleEventDescriptor) -> Bool {
+ guard isQuitEvent(quitAppleEvent) else { return false }
+ guard let senderPIDDescriptor = quitAppleEvent.attributeDescriptor(forKeyword: keySenderPIDAttr) else { return false }
+ let pid = senderPIDDescriptor.int32Value
+ guard let executablePath = getExecutablePath(from: pid) else { return false }
+ wg_log(.debug, message: "aevt/quit Apple event received from: \(executablePath)")
+ if executablePath.hasPrefix("/System/Library/") {
+ let executableName = URL(fileURLWithPath: executablePath, isDirectory: false).lastPathComponent
+ return executableName == "com.apple.CommerceKit.StoreAEService"
+ }
+ return false
+ }
+}
+
+private func isQuitEvent(_ event: NSAppleEventDescriptor) -> Bool {
+ return event.eventClass == kCoreEventClass && event.eventID == kAEQuitApplication
+}
+
+private func getExecutablePath(from pid: pid_t) -> String? {
+ let bufferSize = Int(PATH_MAX)
+ var buffer = Data(capacity: bufferSize)
+ return buffer.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) -> String? in
+ if let basePtr = ptr.baseAddress {
+ let byteCount = proc_pidpath(pid, basePtr, UInt32(bufferSize))
+ return byteCount > 0 ? String(cString: basePtr.bindMemory(to: CChar.self, capacity: bufferSize)) : nil
+ }
+ return nil
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/MainMenu.swift b/Sources/WireGuardApp/UI/macOS/MainMenu.swift
new file mode 100644
index 0000000..92fca4b
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/MainMenu.swift
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+// swiftlint:disable colon
+
+class MainMenu: NSMenu {
+ init() {
+ super.init(title: "")
+ addSubmenu(createApplicationMenu())
+ addSubmenu(createFileMenu())
+ addSubmenu(createEditMenu())
+ addSubmenu(createTunnelMenu())
+ addSubmenu(createWindowMenu())
+ }
+
+ required init(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func addSubmenu(_ menu: NSMenu) {
+ let menuItem = self.addItem(withTitle: "", action: nil, keyEquivalent: "")
+ self.setSubmenu(menu, for: menuItem)
+ }
+
+ private func createApplicationMenu() -> NSMenu {
+ let menu = NSMenu()
+
+ let aboutMenuItem = menu.addItem(withTitle: tr("macMenuAbout"),
+ action: #selector(AppDelegate.aboutClicked), keyEquivalent: "")
+ aboutMenuItem.target = NSApp.delegate
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuViewLog"),
+ action: #selector(TunnelsListTableViewController.handleViewLogAction), keyEquivalent: "")
+
+ menu.addItem(NSMenuItem.separator())
+
+ let hideMenuItem = menu.addItem(withTitle: tr("macMenuHideApp"),
+ action: #selector(NSApplication.hide), keyEquivalent: "h")
+ hideMenuItem.target = NSApp
+ let hideOthersMenuItem = menu.addItem(withTitle: tr("macMenuHideOtherApps"),
+ action: #selector(NSApplication.hideOtherApplications), keyEquivalent: "h")
+ hideOthersMenuItem.keyEquivalentModifierMask = [.command, .option]
+ hideOthersMenuItem.target = NSApp
+ let showAllMenuItem = menu.addItem(withTitle: tr("macMenuShowAllApps"),
+ action: #selector(NSApplication.unhideAllApplications), keyEquivalent: "")
+ showAllMenuItem.target = NSApp
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuQuit"),
+ action: #selector(AppDelegate.confirmAndQuit), keyEquivalent: "q")
+
+ return menu
+ }
+
+ private func createFileMenu() -> NSMenu {
+ let menu = NSMenu(title: tr("macMenuFile"))
+
+ menu.addItem(withTitle: tr("macMenuAddEmptyTunnel"),
+ action: #selector(TunnelsListTableViewController.handleAddEmptyTunnelAction), keyEquivalent: "n")
+ menu.addItem(withTitle: tr("macMenuImportTunnels"),
+ action: #selector(TunnelsListTableViewController.handleImportTunnelAction), keyEquivalent: "o")
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuExportTunnels"),
+ action: #selector(TunnelsListTableViewController.handleExportTunnelsAction), keyEquivalent: "")
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuCloseWindow"), action: #selector(NSWindow.performClose(_:)), keyEquivalent:"w")
+
+ return menu
+ }
+
+ private func createEditMenu() -> NSMenu {
+ let menu = NSMenu(title: tr("macMenuEdit"))
+
+ menu.addItem(withTitle: "", action: #selector(UndoActionRespondable.undo(_:)), keyEquivalent:"z")
+ menu.addItem(withTitle: "", action: #selector(UndoActionRespondable.redo(_:)), keyEquivalent:"Z")
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuCut"), action: #selector(NSText.cut(_:)), keyEquivalent:"x")
+ menu.addItem(withTitle: tr("macMenuCopy"), action: #selector(NSText.copy(_:)), keyEquivalent:"c")
+ menu.addItem(withTitle: tr("macMenuPaste"), action: #selector(NSText.paste(_:)), keyEquivalent:"v")
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuSelectAll"), action: #selector(NSText.selectAll(_:)), keyEquivalent:"a")
+
+ return menu
+ }
+
+ private func createTunnelMenu() -> NSMenu {
+ let menu = NSMenu(title: tr("macMenuTunnel"))
+
+ menu.addItem(withTitle: tr("macMenuToggleStatus"), action: #selector(TunnelDetailTableViewController.handleToggleActiveStatusAction), keyEquivalent:"t")
+
+ menu.addItem(NSMenuItem.separator())
+
+ menu.addItem(withTitle: tr("macMenuEditTunnel"), action: #selector(TunnelDetailTableViewController.handleEditTunnelAction), keyEquivalent:"e")
+ menu.addItem(withTitle: tr("macMenuDeleteSelected"), action: #selector(TunnelsListTableViewController.handleRemoveTunnelAction), keyEquivalent: "")
+
+ return menu
+ }
+
+ private func createWindowMenu() -> NSMenu {
+ let menu = NSMenu(title: tr("macMenuWindow"))
+
+ menu.addItem(withTitle: tr("macMenuMinimize"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent:"m")
+ menu.addItem(withTitle: tr("macMenuZoom"), action: #selector(NSWindow.performZoom(_:)), keyEquivalent:"")
+
+ menu.addItem(NSMenuItem.separator())
+
+ let fullScreenMenuItem = menu.addItem(withTitle: "", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent:"f")
+ fullScreenMenuItem.keyEquivalentModifierMask = [.command, .control]
+
+ return menu
+ }
+}
+
+@objc protocol UndoActionRespondable {
+ func undo(_ sender: AnyObject)
+ func redo(_ sender: AnyObject)
+}
diff --git a/Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift b/Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift
new file mode 100644
index 0000000..4ca4f05
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import AppKit
+
+extension NSColor {
+
+ convenience init(hex: String) {
+ var hexString = hex.uppercased()
+
+ if hexString.hasPrefix("#") {
+ hexString.remove(at: hexString.startIndex)
+ }
+
+ if hexString.count != 6 {
+ fatalError("Invalid hex string \(hex)")
+ }
+
+ var rgb: UInt32 = 0
+ Scanner(string: hexString).scanHexInt32(&rgb)
+
+ self.init(red: CGFloat((rgb >> 16) & 0xff) / 255.0, green: CGFloat((rgb >> 8) & 0xff) / 255.0, blue: CGFloat((rgb >> 0) & 0xff) / 255.0, alpha: 1)
+ }
+
+}
diff --git a/Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift b/Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift
new file mode 100644
index 0000000..979b123
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+extension NSTableView {
+ func dequeueReusableCell<T: NSView>() -> T {
+ let identifier = NSUserInterfaceItemIdentifier(NSStringFromClass(T.self))
+ if let cellView = makeView(withIdentifier: identifier, owner: self) {
+ //swiftlint:disable:next force_cast
+ return cellView as! T
+ }
+ let cellView = T()
+ cellView.identifier = identifier
+ return cellView
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift b/Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift
new file mode 100644
index 0000000..8622510
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import WireGuardKit
+
+// We have this in a separate file because we don't want the network extension
+// code to see WireGuardAppError and tr(). Also, this extension is used only on macOS.
+
+extension TunnelConfiguration.ParseError: WireGuardAppError {
+ var alertText: AlertText {
+ switch self {
+ case .invalidLine(let line):
+ return (tr(format: "macAlertInvalidLine (%@)", String(line)), "")
+ case .noInterface:
+ return (tr("macAlertNoInterface"), "")
+ case .multipleInterfaces:
+ return (tr("macAlertMultipleInterfaces"), "")
+ case .interfaceHasNoPrivateKey:
+ return (tr("alertInvalidInterfaceMessagePrivateKeyRequired"), tr("alertInvalidInterfaceMessagePrivateKeyInvalid"))
+ case .interfaceHasInvalidPrivateKey:
+ return (tr("macAlertPrivateKeyInvalid"), tr("alertInvalidInterfaceMessagePrivateKeyInvalid"))
+ case .interfaceHasInvalidListenPort(let value):
+ return (tr(format: "macAlertListenPortInvalid (%@)", value), tr("alertInvalidInterfaceMessageListenPortInvalid"))
+ case .interfaceHasInvalidAddress(let value):
+ return (tr(format: "macAlertAddressInvalid (%@)", value), tr("alertInvalidInterfaceMessageAddressInvalid"))
+ case .interfaceHasInvalidDNS(let value):
+ return (tr(format: "macAlertDNSInvalid (%@)", value), tr("alertInvalidInterfaceMessageDNSInvalid"))
+ case .interfaceHasInvalidMTU(let value):
+ return (tr(format: "macAlertMTUInvalid (%@)", value), tr("alertInvalidInterfaceMessageMTUInvalid"))
+ case .interfaceHasUnrecognizedKey(let value):
+ return (tr(format: "macAlertUnrecognizedInterfaceKey (%@)", value), tr("macAlertInfoUnrecognizedInterfaceKey"))
+ case .peerHasNoPublicKey:
+ return (tr("alertInvalidPeerMessagePublicKeyRequired"), tr("alertInvalidPeerMessagePublicKeyInvalid"))
+ case .peerHasInvalidPublicKey:
+ return (tr("macAlertPublicKeyInvalid"), tr("alertInvalidPeerMessagePublicKeyInvalid"))
+ case .peerHasInvalidPreSharedKey:
+ return (tr("macAlertPreSharedKeyInvalid"), tr("alertInvalidPeerMessagePreSharedKeyInvalid"))
+ case .peerHasInvalidAllowedIP(let value):
+ return (tr(format: "macAlertAllowedIPInvalid (%@)", value), tr("alertInvalidPeerMessageAllowedIPsInvalid"))
+ case .peerHasInvalidEndpoint(let value):
+ return (tr(format: "macAlertEndpointInvalid (%@)", value), tr("alertInvalidPeerMessageEndpointInvalid"))
+ case .peerHasInvalidPersistentKeepAlive(let value):
+ return (tr(format: "macAlertPersistentKeepliveInvalid (%@)", value), tr("alertInvalidPeerMessagePersistentKeepaliveInvalid"))
+ case .peerHasUnrecognizedKey(let value):
+ return (tr(format: "macAlertUnrecognizedPeerKey (%@)", value), tr("macAlertInfoUnrecognizedPeerKey"))
+ case .peerHasInvalidTransferBytes(let line):
+ return (tr(format: "macAlertInvalidLine (%@)", String(line)), "")
+ case .peerHasInvalidLastHandshakeTime(let line):
+ return (tr(format: "macAlertInvalidLine (%@)", String(line)), "")
+ case .multiplePeersWithSamePublicKey:
+ return (tr("alertInvalidPeerMessagePublicKeyDuplicated"), "")
+ case .multipleEntriesForKey(let value):
+ return (tr(format: "macAlertMultipleEntriesForKey (%@)", value), "")
+ }
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/StatusItemController.swift b/Sources/WireGuardApp/UI/macOS/StatusItemController.swift
new file mode 100644
index 0000000..ef0ccd0
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/StatusItemController.swift
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class StatusItemController {
+ var currentTunnel: TunnelContainer? {
+ didSet {
+ updateStatusItemImage()
+ }
+ }
+
+ let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+ private let statusBarImageWhenActive = NSImage(named: "StatusBarIcon")!
+ private let statusBarImageWhenInactive = NSImage(named: "StatusBarIconDimmed")!
+
+ private let animationImages = [
+ NSImage(named: "StatusBarIconDot1")!,
+ NSImage(named: "StatusBarIconDot2")!,
+ NSImage(named: "StatusBarIconDot3")!
+ ]
+ private var animationImageIndex: Int = 0
+ private var animationTimer: Timer?
+
+ init() {
+ updateStatusItemImage()
+ }
+
+ func updateStatusItemImage() {
+ guard let currentTunnel = currentTunnel else {
+ stopActivatingAnimation()
+ statusItem.button?.image = statusBarImageWhenInactive
+ return
+ }
+ switch currentTunnel.status {
+ case .inactive:
+ stopActivatingAnimation()
+ statusItem.button?.image = statusBarImageWhenInactive
+ case .active:
+ stopActivatingAnimation()
+ statusItem.button?.image = statusBarImageWhenActive
+ case .activating, .waiting, .reasserting, .restarting, .deactivating:
+ startActivatingAnimation()
+ }
+ }
+
+ func startActivatingAnimation() {
+ guard animationTimer == nil else { return }
+ let timer = Timer(timeInterval: 0.3, repeats: true) { [weak self] _ in
+ guard let self = self else { return }
+ self.statusItem.button?.image = self.animationImages[self.animationImageIndex]
+ self.animationImageIndex = (self.animationImageIndex + 1) % self.animationImages.count
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ animationTimer = timer
+ }
+
+ func stopActivatingAnimation() {
+ guard let timer = self.animationTimer else { return }
+ timer.invalidate()
+ animationTimer = nil
+ animationImageIndex = 0
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/StatusMenu.swift b/Sources/WireGuardApp/UI/macOS/StatusMenu.swift
new file mode 100644
index 0000000..ec9ffe8
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/StatusMenu.swift
@@ -0,0 +1,234 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+protocol StatusMenuWindowDelegate: class {
+ func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?)
+}
+
+class StatusMenu: NSMenu {
+
+ let tunnelsManager: TunnelsManager
+
+ var statusMenuItem: NSMenuItem?
+ var networksMenuItem: NSMenuItem?
+ var deactivateMenuItem: NSMenuItem?
+ var firstTunnelMenuItemIndex = 0
+ var numberOfTunnelMenuItems = 0
+
+ var currentTunnel: TunnelContainer? {
+ didSet {
+ updateStatusMenuItems(with: currentTunnel)
+ }
+ }
+ weak var windowDelegate: StatusMenuWindowDelegate?
+
+ init(tunnelsManager: TunnelsManager) {
+ self.tunnelsManager = tunnelsManager
+ super.init(title: tr("macMenuTitle"))
+
+ addStatusMenuItems()
+ addItem(NSMenuItem.separator())
+
+ firstTunnelMenuItemIndex = numberOfItems
+ let isAdded = addTunnelMenuItems()
+ if isAdded {
+ addItem(NSMenuItem.separator())
+ }
+ addTunnelManagementItems()
+ addItem(NSMenuItem.separator())
+ addApplicationItems()
+ }
+
+ required init(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func addStatusMenuItems() {
+ let statusTitle = tr(format: "macStatus (%@)", tr("tunnelStatusInactive"))
+ let statusMenuItem = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "")
+ statusMenuItem.isEnabled = false
+ addItem(statusMenuItem)
+ let networksMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ networksMenuItem.isEnabled = false
+ networksMenuItem.isHidden = true
+ addItem(networksMenuItem)
+ let deactivateMenuItem = NSMenuItem(title: tr("macToggleStatusButtonDeactivate"), action: #selector(deactivateClicked), keyEquivalent: "")
+ deactivateMenuItem.target = self
+ deactivateMenuItem.isHidden = true
+ addItem(deactivateMenuItem)
+ self.statusMenuItem = statusMenuItem
+ self.networksMenuItem = networksMenuItem
+ self.deactivateMenuItem = deactivateMenuItem
+ }
+
+ func updateStatusMenuItems(with tunnel: TunnelContainer?) {
+ guard let statusMenuItem = statusMenuItem, let networksMenuItem = networksMenuItem, let deactivateMenuItem = deactivateMenuItem else { return }
+ guard let tunnel = tunnel else {
+ statusMenuItem.title = tr(format: "macStatus (%@)", tr("tunnelStatusInactive"))
+ networksMenuItem.title = ""
+ networksMenuItem.isHidden = true
+ deactivateMenuItem.isHidden = true
+ return
+ }
+ var statusText: String
+
+ switch tunnel.status {
+ case .waiting:
+ statusText = tr("tunnelStatusWaiting")
+ case .inactive:
+ statusText = tr("tunnelStatusInactive")
+ case .activating:
+ statusText = tr("tunnelStatusActivating")
+ case .active:
+ statusText = tr("tunnelStatusActive")
+ case .deactivating:
+ statusText = tr("tunnelStatusDeactivating")
+ case .reasserting:
+ statusText = tr("tunnelStatusReasserting")
+ case .restarting:
+ statusText = tr("tunnelStatusRestarting")
+ }
+
+ statusMenuItem.title = tr(format: "macStatus (%@)", statusText)
+
+ if tunnel.status == .inactive {
+ networksMenuItem.title = ""
+ networksMenuItem.isHidden = true
+ } else {
+ let allowedIPs = tunnel.tunnelConfiguration?.peers.flatMap { $0.allowedIPs }.map { $0.stringRepresentation }.joined(separator: ", ") ?? ""
+ if !allowedIPs.isEmpty {
+ networksMenuItem.title = tr(format: "macMenuNetworks (%@)", allowedIPs)
+ } else {
+ networksMenuItem.title = tr("macMenuNetworksNone")
+ }
+ networksMenuItem.isHidden = false
+ }
+ deactivateMenuItem.isHidden = tunnel.status != .active
+ }
+
+ func addTunnelMenuItems() -> Bool {
+ let numberOfTunnels = tunnelsManager.numberOfTunnels()
+ for index in 0 ..< tunnelsManager.numberOfTunnels() {
+ let tunnel = tunnelsManager.tunnel(at: index)
+ insertTunnelMenuItem(for: tunnel, at: numberOfTunnelMenuItems)
+ }
+ return numberOfTunnels > 0
+ }
+
+ func addTunnelManagementItems() {
+ let manageItem = NSMenuItem(title: tr("macMenuManageTunnels"), action: #selector(manageTunnelsClicked), keyEquivalent: "")
+ manageItem.target = self
+ addItem(manageItem)
+ let importItem = NSMenuItem(title: tr("macMenuImportTunnels"), action: #selector(importTunnelsClicked), keyEquivalent: "")
+ importItem.target = self
+ addItem(importItem)
+ }
+
+ func addApplicationItems() {
+ let aboutItem = NSMenuItem(title: tr("macMenuAbout"), action: #selector(AppDelegate.aboutClicked), keyEquivalent: "")
+ aboutItem.target = NSApp.delegate
+ addItem(aboutItem)
+ let quitItem = NSMenuItem(title: tr("macMenuQuit"), action: #selector(AppDelegate.quit), keyEquivalent: "")
+ quitItem.target = NSApp.delegate
+ addItem(quitItem)
+ }
+
+ @objc func deactivateClicked() {
+ if let currentTunnel = currentTunnel {
+ tunnelsManager.startDeactivation(of: currentTunnel)
+ }
+ }
+
+ @objc func tunnelClicked(sender: AnyObject) {
+ guard let tunnelMenuItem = sender as? TunnelMenuItem else { return }
+ if tunnelMenuItem.state == .off {
+ tunnelsManager.startActivation(of: tunnelMenuItem.tunnel)
+ } else {
+ tunnelsManager.startDeactivation(of: tunnelMenuItem.tunnel)
+ }
+ }
+
+ @objc func manageTunnelsClicked() {
+ windowDelegate?.showManageTunnelsWindow(completion: nil)
+ }
+
+ @objc func importTunnelsClicked() {
+ windowDelegate?.showManageTunnelsWindow { [weak self] manageTunnelsWindow in
+ guard let self = self else { return }
+ guard let manageTunnelsWindow = manageTunnelsWindow else { return }
+ ImportPanelPresenter.presentImportPanel(tunnelsManager: self.tunnelsManager,
+ sourceVC: manageTunnelsWindow.contentViewController)
+ }
+ }
+}
+
+extension StatusMenu {
+ func insertTunnelMenuItem(for tunnel: TunnelContainer, at tunnelIndex: Int) {
+ let menuItem = TunnelMenuItem(tunnel: tunnel, action: #selector(tunnelClicked(sender:)))
+ menuItem.target = self
+ menuItem.isHidden = !tunnel.isTunnelAvailableToUser
+ insertItem(menuItem, at: firstTunnelMenuItemIndex + tunnelIndex)
+ if numberOfTunnelMenuItems == 0 {
+ insertItem(NSMenuItem.separator(), at: firstTunnelMenuItemIndex + tunnelIndex + 1)
+ }
+ numberOfTunnelMenuItems += 1
+ }
+
+ func removeTunnelMenuItem(at tunnelIndex: Int) {
+ removeItem(at: firstTunnelMenuItemIndex + tunnelIndex)
+ numberOfTunnelMenuItems -= 1
+ if numberOfTunnelMenuItems == 0 {
+ if let firstItem = item(at: firstTunnelMenuItemIndex), firstItem.isSeparatorItem {
+ removeItem(at: firstTunnelMenuItemIndex)
+ }
+ }
+ }
+
+ func moveTunnelMenuItem(from oldTunnelIndex: Int, to newTunnelIndex: Int) {
+ guard let oldMenuItem = item(at: firstTunnelMenuItemIndex + oldTunnelIndex) as? TunnelMenuItem else { return }
+ let oldMenuItemTunnel = oldMenuItem.tunnel
+ removeItem(at: firstTunnelMenuItemIndex + oldTunnelIndex)
+ let menuItem = TunnelMenuItem(tunnel: oldMenuItemTunnel, action: #selector(tunnelClicked(sender:)))
+ menuItem.target = self
+ insertItem(menuItem, at: firstTunnelMenuItemIndex + newTunnelIndex)
+
+ }
+}
+
+class TunnelMenuItem: NSMenuItem {
+
+ var tunnel: TunnelContainer
+
+ private var statusObservationToken: AnyObject?
+ private var nameObservationToken: AnyObject?
+
+ init(tunnel: TunnelContainer, action selector: Selector?) {
+ self.tunnel = tunnel
+ super.init(title: tunnel.name, action: selector, keyEquivalent: "")
+ updateStatus()
+ let statusObservationToken = tunnel.observe(\.status) { [weak self] _, _ in
+ self?.updateStatus()
+ }
+ updateTitle()
+ let nameObservationToken = tunnel.observe(\TunnelContainer.name) { [weak self] _, _ in
+ self?.updateTitle()
+ }
+ self.statusObservationToken = statusObservationToken
+ self.nameObservationToken = nameObservationToken
+ }
+
+ required init(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func updateTitle() {
+ title = tunnel.name
+ }
+
+ func updateStatus() {
+ let shouldShowCheckmark = (tunnel.status != .inactive && tunnel.status != .deactivating)
+ state = shouldShowCheckmark ? .on : .off
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift b/Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift
new file mode 100644
index 0000000..69cc533
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift
@@ -0,0 +1,119 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+// Keeps track of tunnels and informs the following objects of changes in tunnels:
+// - Status menu
+// - Status item controller
+// - Tunnels list view controller in the Manage Tunnels window
+
+class TunnelsTracker {
+
+ weak var statusMenu: StatusMenu? {
+ didSet {
+ statusMenu?.currentTunnel = currentTunnel
+ }
+ }
+ weak var statusItemController: StatusItemController? {
+ didSet {
+ statusItemController?.currentTunnel = currentTunnel
+ }
+ }
+ weak var manageTunnelsRootVC: ManageTunnelsRootViewController?
+
+ private var tunnelsManager: TunnelsManager
+ private var tunnelStatusObservers = [AnyObject]()
+ private(set) var currentTunnel: TunnelContainer? {
+ didSet {
+ statusMenu?.currentTunnel = currentTunnel
+ statusItemController?.currentTunnel = currentTunnel
+ }
+ }
+
+ init(tunnelsManager: TunnelsManager) {
+ self.tunnelsManager = tunnelsManager
+ currentTunnel = tunnelsManager.tunnelInOperation()
+
+ for index in 0 ..< tunnelsManager.numberOfTunnels() {
+ let tunnel = tunnelsManager.tunnel(at: index)
+ let statusObservationToken = observeStatus(of: tunnel)
+ tunnelStatusObservers.insert(statusObservationToken, at: index)
+ }
+
+ tunnelsManager.tunnelsListDelegate = self
+ tunnelsManager.activationDelegate = self
+ }
+
+ func observeStatus(of tunnel: TunnelContainer) -> AnyObject {
+ return tunnel.observe(\.status) { [weak self] tunnel, _ in
+ guard let self = self else { return }
+ if tunnel.status == .deactivating || tunnel.status == .inactive {
+ if self.currentTunnel == tunnel {
+ self.currentTunnel = self.tunnelsManager.tunnelInOperation()
+ }
+ } else {
+ self.currentTunnel = tunnel
+ }
+ }
+ }
+}
+
+extension TunnelsTracker: TunnelsManagerListDelegate {
+ func tunnelAdded(at index: Int) {
+ let tunnel = tunnelsManager.tunnel(at: index)
+ if tunnel.status != .deactivating && tunnel.status != .inactive {
+ self.currentTunnel = tunnel
+ }
+ let statusObservationToken = observeStatus(of: tunnel)
+ tunnelStatusObservers.insert(statusObservationToken, at: index)
+
+ statusMenu?.insertTunnelMenuItem(for: tunnel, at: index)
+ manageTunnelsRootVC?.tunnelsListVC?.tunnelAdded(at: index)
+ }
+
+ func tunnelModified(at index: Int) {
+ manageTunnelsRootVC?.tunnelsListVC?.tunnelModified(at: index)
+ }
+
+ func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
+ let statusObserver = tunnelStatusObservers.remove(at: oldIndex)
+ tunnelStatusObservers.insert(statusObserver, at: newIndex)
+
+ statusMenu?.moveTunnelMenuItem(from: oldIndex, to: newIndex)
+ manageTunnelsRootVC?.tunnelsListVC?.tunnelMoved(from: oldIndex, to: newIndex)
+ }
+
+ func tunnelRemoved(at index: Int, tunnel: TunnelContainer) {
+ tunnelStatusObservers.remove(at: index)
+
+ statusMenu?.removeTunnelMenuItem(at: index)
+ manageTunnelsRootVC?.tunnelsListVC?.tunnelRemoved(at: index)
+ }
+}
+
+extension TunnelsTracker: TunnelsManagerActivationDelegate {
+ func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) {
+ if let manageTunnelsRootVC = manageTunnelsRootVC, manageTunnelsRootVC.view.window?.isVisible ?? false {
+ ErrorPresenter.showErrorAlert(error: error, from: manageTunnelsRootVC)
+ } else {
+ ErrorPresenter.showErrorAlert(error: error, from: nil)
+ }
+ }
+
+ func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) {
+ // Nothing to do
+ }
+
+ func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) {
+ if let manageTunnelsRootVC = manageTunnelsRootVC, manageTunnelsRootVC.view.window?.isVisible ?? false {
+ ErrorPresenter.showErrorAlert(error: error, from: manageTunnelsRootVC)
+ } else {
+ ErrorPresenter.showErrorAlert(error: error, from: nil)
+ }
+ }
+
+ func tunnelActivationSucceeded(tunnel: TunnelContainer) {
+ // Nothing to do
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift b/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift
new file mode 100644
index 0000000..4d15f5e
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ButtonRow: NSView {
+ let button: NSButton = {
+ let button = NSButton()
+ button.title = ""
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ var buttonTitle: String {
+ get { return button.title }
+ set(value) { button.title = value }
+ }
+
+ var isButtonEnabled: Bool {
+ get { return button.isEnabled }
+ set(value) { button.isEnabled = value }
+ }
+
+ var buttonToolTip: String {
+ get { return button.toolTip ?? "" }
+ set(value) { button.toolTip = value }
+ }
+
+ var onButtonClicked: (() -> Void)?
+ var observationToken: AnyObject?
+
+ override var intrinsicContentSize: NSSize {
+ return NSSize(width: NSView.noIntrinsicMetric, height: button.intrinsicContentSize.height)
+ }
+
+ init() {
+ super.init(frame: CGRect.zero)
+
+ button.target = self
+ button.action = #selector(buttonClicked)
+
+ addSubview(button)
+ button.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ button.centerYAnchor.constraint(equalTo: self.centerYAnchor),
+ button.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 155),
+ button.widthAnchor.constraint(greaterThanOrEqualToConstant: 100)
+ ])
+ }
+
+ required init?(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ @objc func buttonClicked() {
+ onButtonClicked?()
+ }
+
+ override func prepareForReuse() {
+ buttonTitle = ""
+ buttonToolTip = ""
+ onButtonClicked = nil
+ observationToken = nil
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift
new file mode 100644
index 0000000..5229d50
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+protocol ConfTextColorTheme {
+ static var defaultColor: NSColor { get }
+ static var colorMap: [UInt32: NSColor] { get }
+}
+
+struct ConfTextAquaColorTheme: ConfTextColorTheme {
+ static let defaultColor = NSColor(hex: "#000000")
+ static let colorMap: [UInt32: NSColor] = [
+ HighlightSection.rawValue: NSColor(hex: "#326D74"), // Class name in Xcode
+ HighlightField.rawValue: NSColor(hex: "#9B2393"), // Keywords in Xcode
+ HighlightPublicKey.rawValue: NSColor(hex: "#643820"), // Preprocessor directives in Xcode
+ HighlightPrivateKey.rawValue: NSColor(hex: "#643820"), // Preprocessor directives in Xcode
+ HighlightPresharedKey.rawValue: NSColor(hex: "#643820"), // Preprocessor directives in Xcode
+ HighlightIP.rawValue: NSColor(hex: "#0E0EFF"), // URLs in Xcode
+ HighlightHost.rawValue: NSColor(hex: "#0E0EFF"), // URLs in Xcode
+ HighlightCidr.rawValue: NSColor(hex: "#815F03"), // Attributes in Xcode
+ HighlightPort.rawValue: NSColor(hex: "#815F03"), // Attributes in Xcode
+ HighlightMTU.rawValue: NSColor(hex: "#1C00CF"), // Numbers in Xcode
+ HighlightKeepalive.rawValue: NSColor(hex: "#1C00CF"), // Numbers in Xcode
+ HighlightComment.rawValue: NSColor(hex: "#536579"), // Comments in Xcode
+ HighlightError.rawValue: NSColor(hex: "#C41A16") // Strings in Xcode
+ ]
+}
+
+struct ConfTextDarkAquaColorTheme: ConfTextColorTheme {
+ static let defaultColor = NSColor(hex: "#FFFFFF") // Plain text in Xcode
+ static let colorMap: [UInt32: NSColor] = [
+ HighlightSection.rawValue: NSColor(hex: "#91D462"), // Class name in Xcode
+ HighlightField.rawValue: NSColor(hex: "#FC5FA3"), // Keywords in Xcode
+ HighlightPublicKey.rawValue: NSColor(hex: "#FD8F3F"), // Preprocessor directives in Xcode
+ HighlightPrivateKey.rawValue: NSColor(hex: "#FD8F3F"), // Preprocessor directives in Xcode
+ HighlightPresharedKey.rawValue: NSColor(hex: "#FD8F3F"), // Preprocessor directives in Xcode
+ HighlightIP.rawValue: NSColor(hex: "#53A5FB"), // URLs in Xcode
+ HighlightHost.rawValue: NSColor(hex: "#53A5FB"), // URLs in Xcode
+ HighlightCidr.rawValue: NSColor(hex: "#75B492"), // Attributes in Xcode
+ HighlightPort.rawValue: NSColor(hex: "#75B492"), // Attributes in Xcode
+ HighlightMTU.rawValue: NSColor(hex: "#9686F5"), // Numbers in Xcode
+ HighlightKeepalive.rawValue: NSColor(hex: "#9686F5"), // Numbers in Xcode
+ HighlightComment.rawValue: NSColor(hex: "#6C7986"), // Comments in Xcode
+ HighlightError.rawValue: NSColor(hex: "#FF4C4C") // Strings in Xcode
+ ]
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift
new file mode 100644
index 0000000..3c92db3
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+private let fontSize: CGFloat = 15
+
+class ConfTextStorage: NSTextStorage {
+ let defaultFont = NSFontManager.shared.convertWeight(true, of: NSFont.systemFont(ofSize: fontSize))
+ private let boldFont = NSFont.boldSystemFont(ofSize: fontSize)
+ private lazy var italicFont = NSFontManager.shared.convert(defaultFont, toHaveTrait: .italicFontMask)
+
+ private var textColorTheme: ConfTextColorTheme.Type?
+
+ private let backingStore: NSMutableAttributedString
+ private(set) var hasError = false
+ private(set) var privateKeyString: String?
+
+ private(set) var hasOnePeer: Bool = false
+ private(set) var lastOnePeerAllowedIPs = [String]()
+ private(set) var lastOnePeerDNSServers = [String]()
+ private(set) var lastOnePeerHasPublicKey = false
+
+ override init() {
+ backingStore = NSMutableAttributedString(string: "")
+ super.init()
+ }
+
+ required init?(coder aDecoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ required init?(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) {
+ fatalError("init(pasteboardPropertyList:ofType:) has not been implemented")
+ }
+
+ func nonColorAttributes(for highlightType: highlight_type) -> [NSAttributedString.Key: Any] {
+ switch highlightType.rawValue {
+ case HighlightSection.rawValue, HighlightField.rawValue:
+ return [.font: boldFont]
+ case HighlightPublicKey.rawValue, HighlightPrivateKey.rawValue, HighlightPresharedKey.rawValue,
+ HighlightIP.rawValue, HighlightCidr.rawValue, HighlightHost.rawValue, HighlightPort.rawValue,
+ HighlightMTU.rawValue, HighlightKeepalive.rawValue, HighlightDelimiter.rawValue:
+ return [.font: defaultFont]
+ case HighlightComment.rawValue:
+ return [.font: italicFont]
+ case HighlightError.rawValue:
+ return [.font: defaultFont, .underlineStyle: 1]
+ default:
+ return [:]
+ }
+ }
+
+ func updateAttributes(for textColorTheme: ConfTextColorTheme.Type) {
+ self.textColorTheme = textColorTheme
+ highlightSyntax()
+ }
+
+ override var string: String {
+ return backingStore.string
+ }
+
+ override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] {
+ return backingStore.attributes(at: location, effectiveRange: range)
+ }
+
+ override func replaceCharacters(in range: NSRange, with str: String) {
+ beginEditing()
+ backingStore.replaceCharacters(in: range, with: str)
+ edited(.editedCharacters, range: range, changeInLength: str.count - range.length)
+ endEditing()
+ }
+
+ override func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) {
+ beginEditing()
+ backingStore.replaceCharacters(in: range, with: attrString)
+ edited(.editedCharacters, range: range, changeInLength: attrString.length - range.length)
+ endEditing()
+ }
+
+ override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
+ beginEditing()
+ backingStore.setAttributes(attrs, range: range)
+ edited(.editedAttributes, range: range, changeInLength: 0)
+ endEditing()
+ }
+
+ func resetLastPeer() {
+ hasOnePeer = false
+ lastOnePeerAllowedIPs = []
+ lastOnePeerDNSServers = []
+ lastOnePeerHasPublicKey = false
+ }
+
+ func evaluateExcludePrivateIPs(highlightSpans: UnsafePointer<highlight_span>) {
+ var spans = highlightSpans
+ enum FieldType: String {
+ case dns
+ case allowedips
+ }
+ var fieldType: FieldType?
+ resetLastPeer()
+ while spans.pointee.type != HighlightEnd {
+ let span = spans.pointee
+ var substring = backingStore.attributedSubstring(from: NSRange(location: span.start, length: span.len)).string.lowercased()
+
+ if span.type == HighlightError {
+ resetLastPeer()
+ return
+ } else if span.type == HighlightSection {
+ if substring == "[peer]" {
+ if hasOnePeer {
+ resetLastPeer()
+ return
+ }
+ hasOnePeer = true
+ }
+ } else if span.type == HighlightField {
+ fieldType = FieldType(rawValue: substring)
+ } else if span.type == HighlightIP && fieldType == .dns {
+ lastOnePeerDNSServers.append(substring)
+ } else if span.type == HighlightIP && fieldType == .allowedips {
+ let next = spans.successor()
+ let nextnext = next.successor()
+ if next.pointee.type == HighlightDelimiter && nextnext.pointee.type == HighlightCidr {
+ substring += backingStore.attributedSubstring(from: NSRange(location: next.pointee.start, length: next.pointee.len)).string +
+ backingStore.attributedSubstring(from: NSRange(location: nextnext.pointee.start, length: nextnext.pointee.len)).string
+ }
+ lastOnePeerAllowedIPs.append(substring)
+ } else if span.type == HighlightPublicKey {
+ lastOnePeerHasPublicKey = true
+ }
+ spans = spans.successor()
+ }
+ }
+
+ func highlightSyntax() {
+ guard let textColorTheme = textColorTheme else { return }
+ hasError = false
+ privateKeyString = nil
+
+ let fullTextRange = NSRange(location: 0, length: (backingStore.string as NSString).length)
+
+ backingStore.beginEditing()
+ let defaultAttributes: [NSAttributedString.Key: Any] = [
+ .foregroundColor: textColorTheme.defaultColor,
+ .font: defaultFont
+ ]
+ backingStore.setAttributes(defaultAttributes, range: fullTextRange)
+ var spans = highlight_config(backingStore.string)!
+ evaluateExcludePrivateIPs(highlightSpans: spans)
+
+ let spansStart = spans
+ while spans.pointee.type != HighlightEnd {
+ let span = spans.pointee
+
+ let range = NSRange(location: span.start, length: span.len)
+ backingStore.setAttributes(nonColorAttributes(for: span.type), range: range)
+ let color = textColorTheme.colorMap[span.type.rawValue, default: textColorTheme.defaultColor]
+ backingStore.addAttribute(.foregroundColor, value: color, range: range)
+
+ if span.type == HighlightError {
+ hasError = true
+ }
+
+ if span.type == HighlightPrivateKey {
+ privateKeyString = backingStore.attributedSubstring(from: NSRange(location: span.start, length: span.len)).string
+ }
+
+ spans = spans.successor()
+ }
+ backingStore.endEditing()
+ free(spansStart)
+
+ beginEditing()
+ edited(.editedAttributes, range: fullTextRange, changeInLength: 0)
+ endEditing()
+ }
+
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift
new file mode 100644
index 0000000..6016e08
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ConfTextView: NSTextView {
+
+ private let confTextStorage = ConfTextStorage()
+
+ @objc dynamic var hasError: Bool = false
+ @objc dynamic var privateKeyString: String?
+ @objc dynamic var singlePeerAllowedIPs: [String]?
+
+ override var string: String {
+ didSet {
+ confTextStorage.highlightSyntax()
+ updateConfigData()
+ }
+ }
+
+ init() {
+ let textContainer = NSTextContainer()
+ let layoutManager = NSLayoutManager()
+ layoutManager.addTextContainer(textContainer)
+ confTextStorage.addLayoutManager(layoutManager)
+ super.init(frame: CGRect(x: 0, y: 0, width: 1, height: 60), textContainer: textContainer)
+ font = confTextStorage.defaultFont
+ allowsUndo = true
+ isAutomaticSpellingCorrectionEnabled = false
+ isAutomaticDataDetectionEnabled = false
+ isAutomaticLinkDetectionEnabled = false
+ isAutomaticTextCompletionEnabled = false
+ isAutomaticTextReplacementEnabled = false
+ isAutomaticDashSubstitutionEnabled = false
+ isAutomaticQuoteSubstitutionEnabled = false
+ updateTheme()
+ delegate = self
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidChangeEffectiveAppearance() {
+ updateTheme()
+ }
+
+ private func updateTheme() {
+ switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) ?? .aqua {
+ case .darkAqua:
+ confTextStorage.updateAttributes(for: ConfTextDarkAquaColorTheme.self)
+ default:
+ confTextStorage.updateAttributes(for: ConfTextAquaColorTheme.self)
+ }
+ }
+
+ private func updateConfigData() {
+ if hasError != confTextStorage.hasError {
+ hasError = confTextStorage.hasError
+ }
+ if privateKeyString != confTextStorage.privateKeyString {
+ privateKeyString = confTextStorage.privateKeyString
+ }
+ let hasSyntaxError = confTextStorage.hasError
+ let hasSemanticError = confTextStorage.privateKeyString == nil || !confTextStorage.lastOnePeerHasPublicKey
+ let updatedSinglePeerAllowedIPs = confTextStorage.hasOnePeer && !hasSyntaxError && !hasSemanticError ? confTextStorage.lastOnePeerAllowedIPs : nil
+ if singlePeerAllowedIPs != updatedSinglePeerAllowedIPs {
+ singlePeerAllowedIPs = updatedSinglePeerAllowedIPs
+ }
+ }
+
+ func setConfText(_ text: String) {
+ let fullTextRange = NSRange(location: 0, length: (string as NSString).length)
+ if shouldChangeText(in: fullTextRange, replacementString: text) {
+ replaceCharacters(in: fullTextRange, with: text)
+ didChangeText()
+ }
+ }
+}
+
+extension ConfTextView: NSTextViewDelegate {
+
+ func textDidChange(_ notification: Notification) {
+ confTextStorage.highlightSyntax()
+ updateConfigData()
+ needsDisplay = true
+ }
+
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift b/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift
new file mode 100644
index 0000000..e52ed89
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class DeleteTunnelsConfirmationAlert: NSAlert {
+ var alertDeleteButton: NSButton?
+ var alertCancelButton: NSButton?
+
+ var onDeleteClicked: ((_ completionHandler: @escaping () -> Void) -> Void)?
+
+ override init() {
+ super.init()
+ let alertDeleteButton = addButton(withTitle: tr("macDeleteTunnelConfirmationAlertButtonTitleDelete"))
+ alertDeleteButton.target = self
+ alertDeleteButton.action = #selector(removeTunnelAlertDeleteClicked)
+ self.alertDeleteButton = alertDeleteButton
+ self.alertCancelButton = addButton(withTitle: tr("macDeleteTunnelConfirmationAlertButtonTitleCancel"))
+ }
+
+ @objc func removeTunnelAlertDeleteClicked() {
+ alertDeleteButton?.title = tr("macDeleteTunnelConfirmationAlertButtonTitleDeleting")
+ alertDeleteButton?.isEnabled = false
+ alertCancelButton?.isEnabled = false
+ if let onDeleteClicked = onDeleteClicked {
+ onDeleteClicked { [weak self] in
+ guard let self = self else { return }
+ self.window.sheetParent?.endSheet(self.window)
+ }
+ }
+ }
+
+ func beginSheetModal(for sheetWindow: NSWindow) {
+ beginSheetModal(for: sheetWindow) { _ in }
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift b/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift
new file mode 100644
index 0000000..2f037d8
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift
@@ -0,0 +1,139 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class EditableKeyValueRow: NSView {
+ let keyLabel: NSTextField = {
+ let keyLabel = NSTextField()
+ keyLabel.isEditable = false
+ keyLabel.isSelectable = false
+ keyLabel.isBordered = false
+ keyLabel.alignment = .right
+ keyLabel.maximumNumberOfLines = 1
+ keyLabel.lineBreakMode = .byTruncatingTail
+ keyLabel.backgroundColor = .clear
+ return keyLabel
+ }()
+
+ let valueLabel: NSTextField = {
+ let valueLabel = NSTextField()
+ valueLabel.isSelectable = true
+ valueLabel.maximumNumberOfLines = 1
+ valueLabel.lineBreakMode = .byTruncatingTail
+ return valueLabel
+ }()
+
+ let valueImageView: NSImageView?
+
+ var key: String {
+ get { return keyLabel.stringValue }
+ set(value) { keyLabel.stringValue = value }
+ }
+ var value: String {
+ get { return valueLabel.stringValue }
+ set(value) { valueLabel.stringValue = value }
+ }
+ var isKeyInBold: Bool {
+ get { return keyLabel.font == NSFont.boldSystemFont(ofSize: 0) }
+ set(value) {
+ if value {
+ keyLabel.font = NSFont.boldSystemFont(ofSize: 0)
+ } else {
+ keyLabel.font = NSFont.systemFont(ofSize: 0)
+ }
+ }
+ }
+ var valueImage: NSImage? {
+ get { return valueImageView?.image }
+ set(value) { valueImageView?.image = value }
+ }
+
+ var observationToken: AnyObject?
+
+ override var intrinsicContentSize: NSSize {
+ let height = max(keyLabel.intrinsicContentSize.height, valueLabel.intrinsicContentSize.height)
+ return NSSize(width: NSView.noIntrinsicMetric, height: height)
+ }
+
+ convenience init() {
+ self.init(hasValueImage: false)
+ }
+
+ fileprivate init(hasValueImage: Bool) {
+ valueImageView = hasValueImage ? NSImageView() : nil
+ super.init(frame: CGRect.zero)
+
+ addSubview(keyLabel)
+ addSubview(valueLabel)
+ keyLabel.translatesAutoresizingMaskIntoConstraints = false
+ valueLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ keyLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor),
+ keyLabel.firstBaselineAnchor.constraint(equalTo: valueLabel.firstBaselineAnchor),
+ self.leadingAnchor.constraint(equalTo: keyLabel.leadingAnchor),
+ valueLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor)
+ ])
+
+ let spacing: CGFloat = 5
+ if let valueImageView = valueImageView {
+ addSubview(valueImageView)
+ valueImageView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ valueImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
+ valueImageView.leadingAnchor.constraint(equalTo: keyLabel.trailingAnchor, constant: spacing),
+ valueLabel.leadingAnchor.constraint(equalTo: valueImageView.trailingAnchor)
+ ])
+ } else {
+ NSLayoutConstraint.activate([
+ valueLabel.leadingAnchor.constraint(equalTo: keyLabel.trailingAnchor, constant: spacing)
+ ])
+ }
+
+ keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal)
+ keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+ valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+ let widthConstraint = keyLabel.widthAnchor.constraint(equalToConstant: 150)
+ widthConstraint.priority = .defaultHigh + 1
+ widthConstraint.isActive = true
+ }
+
+ required init?(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func prepareForReuse() {
+ key = ""
+ value = ""
+ isKeyInBold = false
+ observationToken = nil
+ }
+}
+
+class KeyValueRow: EditableKeyValueRow {
+ init() {
+ super.init(hasValueImage: false)
+ valueLabel.isEditable = false
+ valueLabel.isBordered = false
+ valueLabel.backgroundColor = .clear
+ }
+
+ required init?(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+class KeyValueImageRow: EditableKeyValueRow {
+ init() {
+ super.init(hasValueImage: true)
+ valueLabel.isEditable = false
+ valueLabel.isBordered = false
+ valueLabel.backgroundColor = .clear
+ }
+
+ required init?(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift b/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift
new file mode 100644
index 0000000..c1c6cc5
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class LogViewCell: NSTableCellView {
+ var text: String = "" {
+ didSet { textField?.stringValue = text }
+ }
+
+ init() {
+ super.init(frame: .zero)
+
+ let textField = NSTextField(wrappingLabelWithString: "")
+ addSubview(textField)
+ textField.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ textField.leadingAnchor.constraint(equalTo: self.leadingAnchor),
+ textField.trailingAnchor.constraint(equalTo: self.trailingAnchor),
+ textField.topAnchor.constraint(equalTo: self.topAnchor),
+ textField.bottomAnchor.constraint(equalTo: self.bottomAnchor)
+ ])
+
+ self.textField = textField
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func prepareForReuse() {
+ textField?.stringValue = ""
+ }
+}
+
+class LogViewTimestampCell: LogViewCell {
+ override init() {
+ super.init()
+ if let textField = textField {
+ textField.maximumNumberOfLines = 1
+ textField.lineBreakMode = .byClipping
+ textField.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
+ textField.setContentHuggingPriority(.defaultLow, for: .vertical)
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+class LogViewMessageCell: LogViewCell {
+ override init() {
+ super.init()
+ if let textField = textField {
+ textField.maximumNumberOfLines = 0
+ textField.lineBreakMode = .byWordWrapping
+ textField.setContentCompressionResistancePriority(.required, for: .vertical)
+ textField.setContentHuggingPriority(.required, for: .vertical)
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift b/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift
new file mode 100644
index 0000000..2e3de48
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import CoreWLAN
+
+class OnDemandControlsRow: NSView {
+ let keyLabel: NSTextField = {
+ let keyLabel = NSTextField()
+ keyLabel.stringValue = tr("macFieldOnDemand")
+ keyLabel.isEditable = false
+ keyLabel.isSelectable = false
+ keyLabel.isBordered = false
+ keyLabel.alignment = .right
+ keyLabel.maximumNumberOfLines = 1
+ keyLabel.lineBreakMode = .byTruncatingTail
+ keyLabel.backgroundColor = .clear
+ return keyLabel
+ }()
+
+ let onDemandEthernetCheckbox: NSButton = {
+ let checkbox = NSButton()
+ checkbox.title = tr("tunnelOnDemandEthernet")
+ checkbox.setButtonType(.switch)
+ checkbox.state = .off
+ return checkbox
+ }()
+
+ let onDemandWiFiCheckbox: NSButton = {
+ let checkbox = NSButton()
+ checkbox.title = tr("tunnelOnDemandWiFi")
+ checkbox.setButtonType(.switch)
+ checkbox.state = .off
+ return checkbox
+ }()
+
+ static let onDemandSSIDOptions: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [
+ .anySSID, .onlySpecificSSIDs, .exceptSpecificSSIDs
+ ]
+
+ let onDemandSSIDOptionsPopup = NSPopUpButton()
+
+ let onDemandSSIDsField: NSTokenField = {
+ let tokenField = NSTokenField()
+ tokenField.tokenizingCharacterSet = CharacterSet([])
+ tokenField.tokenStyle = .squared
+ NSLayoutConstraint.activate([
+ tokenField.widthAnchor.constraint(greaterThanOrEqualToConstant: 180)
+ ])
+ return tokenField
+ }()
+
+ override var intrinsicContentSize: NSSize {
+ let minHeight: CGFloat = 22
+ let height = max(minHeight, keyLabel.intrinsicContentSize.height,
+ onDemandEthernetCheckbox.intrinsicContentSize.height, onDemandWiFiCheckbox.intrinsicContentSize.height,
+ onDemandSSIDOptionsPopup.intrinsicContentSize.height, onDemandSSIDsField.intrinsicContentSize.height)
+ return NSSize(width: NSView.noIntrinsicMetric, height: height)
+ }
+
+ var onDemandViewModel: ActivateOnDemandViewModel? {
+ didSet { updateControls() }
+ }
+
+ var currentSSIDs: [String]
+
+ init() {
+ currentSSIDs = getCurrentSSIDs()
+ super.init(frame: CGRect.zero)
+
+ onDemandSSIDOptionsPopup.addItems(withTitles: OnDemandControlsRow.onDemandSSIDOptions.map { $0.localizedUIString })
+
+ let stackView = NSStackView()
+ stackView.setViews([onDemandEthernetCheckbox, onDemandWiFiCheckbox, onDemandSSIDOptionsPopup, onDemandSSIDsField], in: .leading)
+ stackView.orientation = .horizontal
+
+ addSubview(keyLabel)
+ addSubview(stackView)
+ keyLabel.translatesAutoresizingMaskIntoConstraints = false
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ keyLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor),
+ stackView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
+ self.leadingAnchor.constraint(equalTo: keyLabel.leadingAnchor),
+ stackView.leadingAnchor.constraint(equalTo: keyLabel.trailingAnchor, constant: 5),
+ stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor)
+ ])
+
+ keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal)
+ keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+
+ let widthConstraint = keyLabel.widthAnchor.constraint(equalToConstant: 150)
+ widthConstraint.priority = .defaultHigh + 1
+ widthConstraint.isActive = true
+
+ NSLayoutConstraint.activate([
+ onDemandEthernetCheckbox.centerYAnchor.constraint(equalTo: stackView.centerYAnchor),
+ onDemandWiFiCheckbox.lastBaselineAnchor.constraint(equalTo: onDemandEthernetCheckbox.lastBaselineAnchor),
+ onDemandSSIDOptionsPopup.lastBaselineAnchor.constraint(equalTo: onDemandEthernetCheckbox.lastBaselineAnchor),
+ onDemandSSIDsField.lastBaselineAnchor.constraint(equalTo: onDemandEthernetCheckbox.lastBaselineAnchor)
+ ])
+
+ onDemandSSIDsField.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+ onDemandEthernetCheckbox.target = self
+ onDemandEthernetCheckbox.action = #selector(ethernetCheckboxToggled)
+
+ onDemandWiFiCheckbox.target = self
+ onDemandWiFiCheckbox.action = #selector(wiFiCheckboxToggled)
+
+ onDemandSSIDOptionsPopup.target = self
+ onDemandSSIDOptionsPopup.action = #selector(ssidOptionsPopupValueChanged)
+
+ onDemandSSIDsField.delegate = self
+
+ updateControls()
+ }
+
+ required init?(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func saveToViewModel() {
+ guard let onDemandViewModel = onDemandViewModel else { return }
+ onDemandViewModel.isNonWiFiInterfaceEnabled = onDemandEthernetCheckbox.state == .on
+ onDemandViewModel.isWiFiInterfaceEnabled = onDemandWiFiCheckbox.state == .on
+ onDemandViewModel.ssidOption = OnDemandControlsRow.onDemandSSIDOptions[onDemandSSIDOptionsPopup.indexOfSelectedItem]
+ onDemandViewModel.selectedSSIDs = (onDemandSSIDsField.objectValue as? [String]) ?? []
+ }
+
+ func updateControls() {
+ guard let onDemandViewModel = onDemandViewModel else { return }
+ onDemandEthernetCheckbox.state = onDemandViewModel.isNonWiFiInterfaceEnabled ? .on : .off
+ onDemandWiFiCheckbox.state = onDemandViewModel.isWiFiInterfaceEnabled ? .on : .off
+ let optionIndex = OnDemandControlsRow.onDemandSSIDOptions.firstIndex(of: onDemandViewModel.ssidOption)
+ onDemandSSIDOptionsPopup.selectItem(at: optionIndex ?? 0)
+ onDemandSSIDsField.objectValue = onDemandViewModel.selectedSSIDs
+ onDemandSSIDOptionsPopup.isHidden = !onDemandViewModel.isWiFiInterfaceEnabled
+ onDemandSSIDsField.isHidden = !onDemandViewModel.isWiFiInterfaceEnabled || onDemandViewModel.ssidOption == .anySSID
+ }
+
+ @objc func ethernetCheckboxToggled() {
+ onDemandViewModel?.isNonWiFiInterfaceEnabled = onDemandEthernetCheckbox.state == .on
+ }
+
+ @objc func wiFiCheckboxToggled() {
+ onDemandViewModel?.isWiFiInterfaceEnabled = onDemandWiFiCheckbox.state == .on
+ updateControls()
+ }
+
+ @objc func ssidOptionsPopupValueChanged() {
+ let selectedIndex = onDemandSSIDOptionsPopup.indexOfSelectedItem
+ onDemandViewModel?.ssidOption = OnDemandControlsRow.onDemandSSIDOptions[selectedIndex]
+ onDemandViewModel?.selectedSSIDs = (onDemandSSIDsField.objectValue as? [String]) ?? []
+ updateControls()
+ if !onDemandSSIDsField.isHidden {
+ onDemandSSIDsField.becomeFirstResponder()
+ }
+ }
+}
+
+extension OnDemandControlsRow: NSTokenFieldDelegate {
+ func tokenField(_ tokenField: NSTokenField, completionsForSubstring substring: String, indexOfToken tokenIndex: Int, indexOfSelectedItem selectedIndex: UnsafeMutablePointer<Int>?) -> [Any]? {
+ return currentSSIDs.filter { $0.hasPrefix(substring) }
+ }
+}
+
+private func getCurrentSSIDs() -> [String] {
+ return CWWiFiClient.shared().interfaces()?.compactMap { $0.ssid() } ?? []
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift b/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift
new file mode 100644
index 0000000..f5e37c1
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class TunnelListRow: NSView {
+ var tunnel: TunnelContainer? {
+ didSet(value) {
+ // Bind to the tunnel's name
+ nameLabel.stringValue = tunnel?.name ?? ""
+ nameObservationToken = tunnel?.observe(\TunnelContainer.name) { [weak self] tunnel, _ in
+ self?.nameLabel.stringValue = tunnel.name
+ }
+ // Bind to the tunnel's status
+ statusImageView.image = TunnelListRow.image(for: tunnel?.status)
+ statusObservationToken = tunnel?.observe(\TunnelContainer.status) { [weak self] tunnel, _ in
+ self?.statusImageView.image = TunnelListRow.image(for: tunnel.status)
+ }
+ }
+ }
+
+ let nameLabel: NSTextField = {
+ let nameLabel = NSTextField()
+ nameLabel.isEditable = false
+ nameLabel.isSelectable = false
+ nameLabel.isBordered = false
+ nameLabel.maximumNumberOfLines = 1
+ nameLabel.lineBreakMode = .byTruncatingTail
+ return nameLabel
+ }()
+
+ let statusImageView = NSImageView()
+
+ private var statusObservationToken: AnyObject?
+ private var nameObservationToken: AnyObject?
+
+ init() {
+ super.init(frame: CGRect.zero)
+
+ addSubview(statusImageView)
+ addSubview(nameLabel)
+ statusImageView.translatesAutoresizingMaskIntoConstraints = false
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
+ nameLabel.backgroundColor = .clear
+ NSLayoutConstraint.activate([
+ self.leadingAnchor.constraint(equalTo: statusImageView.leadingAnchor),
+ statusImageView.trailingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
+ statusImageView.widthAnchor.constraint(equalToConstant: 20),
+ nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor),
+ statusImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor),
+ nameLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor)
+ ])
+ }
+
+ required init?(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ static func image(for status: TunnelStatus?) -> NSImage? {
+ guard let status = status else { return nil }
+ switch status {
+ case .active, .restarting, .reasserting:
+ return NSImage(named: NSImage.statusAvailableName)
+ case .activating, .waiting, .deactivating:
+ return NSImage(named: NSImage.statusPartiallyAvailableName)
+ case .inactive:
+ return NSImage(named: NSImage.statusNoneName)
+ }
+ }
+
+ override func prepareForReuse() {
+ nameLabel.stringValue = ""
+ statusImageView.image = nil
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/highlighter.c b/Sources/WireGuardApp/UI/macOS/View/highlighter.c
new file mode 100644
index 0000000..e0d4e04
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/highlighter.c
@@ -0,0 +1,641 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
+ */
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+#include "highlighter.h"
+
+typedef struct {
+ const char *s;
+ size_t len;
+} string_span_t;
+
+static bool is_decimal(char c)
+{
+ return c >= '0' && c <= '9';
+}
+
+static bool is_hexadecimal(char c)
+{
+ return is_decimal(c) || ((c | 32) >= 'a' && (c | 32) <= 'f');
+}
+
+static bool is_alphabet(char c)
+{
+ return (c | 32) >= 'a' && (c | 32) <= 'z';
+}
+
+static bool is_same(string_span_t s, const char *c)
+{
+ size_t len = strlen(c);
+
+ if (len != s.len)
+ return false;
+ return !memcmp(s.s, c, len);
+}
+
+static bool is_caseless_same(string_span_t s, const char *c)
+{
+ size_t len = strlen(c);
+
+ if (len != s.len)
+ return false;
+ for (size_t i = 0; i < len; ++i) {
+ char a = c[i], b = s.s[i];
+ if ((unsigned)a - 'a' < 26)
+ a &= 95;
+ if ((unsigned)b - 'a' < 26)
+ b &= 95;
+ if (a != b)
+ return false;
+ }
+ return true;
+}
+
+static bool is_valid_key(string_span_t s)
+{
+ if (s.len != 44 || s.s[43] != '=')
+ return false;
+
+ for (size_t i = 0; i < 42; ++i) {
+ if (!is_decimal(s.s[i]) && !is_alphabet(s.s[i]) &&
+ s.s[i] != '/' && s.s[i] != '+')
+ return false;
+ }
+ switch (s.s[42]) {
+ case 'A':
+ case 'E':
+ case 'I':
+ case 'M':
+ case 'Q':
+ case 'U':
+ case 'Y':
+ case 'c':
+ case 'g':
+ case 'k':
+ case 'o':
+ case 's':
+ case 'w':
+ case '4':
+ case '8':
+ case '0':
+ break;
+ default:
+ return false;
+ }
+ return true;
+}
+
+static bool is_valid_hostname(string_span_t s)
+{
+ size_t num_digit = 0, num_entity = s.len;
+
+ if (s.len > 63 || !s.len)
+ return false;
+ if (s.s[0] == '-' || s.s[s.len - 1] == '-')
+ return false;
+ if (s.s[0] == '.' || s.s[s.len - 1] == '.')
+ return false;
+
+ for (size_t i = 0; i < s.len; ++i) {
+ if (is_decimal(s.s[i])) {
+ ++num_digit;
+ continue;
+ }
+ if (s.s[i] == '.') {
+ --num_entity;
+ continue;
+ }
+
+ if (!is_alphabet(s.s[i]) && s.s[i] != '-')
+ return false;
+
+ if (i && s.s[i] == '.' && s.s[i - 1] == '.')
+ return false;
+ }
+ return num_digit != num_entity;
+}
+
+static bool is_valid_ipv4(string_span_t s)
+{
+ for (size_t j, i = 0, pos = 0; i < 4 && pos < s.len; ++i) {
+ uint32_t val = 0;
+
+ for (j = 0; j < 3 && pos + j < s.len && is_decimal(s.s[pos + j]); ++j)
+ val = 10 * val + s.s[pos + j] - '0';
+ if (j == 0 || (j > 1 && s.s[pos] == '0') || val > 255)
+ return false;
+ if (pos + j == s.len && i == 3)
+ return true;
+ if (s.s[pos + j] != '.')
+ return false;
+ pos += j + 1;
+ }
+ return false;
+}
+
+static bool is_valid_ipv6(string_span_t s)
+{
+ size_t pos = 0;
+ bool seen_colon = false;
+
+ if (s.len < 2)
+ return false;
+ if (s.s[pos] == ':' && s.s[++pos] != ':')
+ return false;
+ if (s.s[s.len - 1] == ':' && s.s[s.len - 2] != ':')
+ return false;
+
+ for (size_t j, i = 0; pos < s.len; ++i) {
+ if (s.s[pos] == ':' && !seen_colon) {
+ seen_colon = true;
+ if (++pos == s.len)
+ break;
+ if (i == 7)
+ return false;
+ continue;
+ }
+ for (j = 0; j < 4 && pos + j < s.len && is_hexadecimal(s.s[pos + j]); ++j);
+ if (j == 0)
+ return false;
+ if (pos + j == s.len && (seen_colon || i == 7))
+ break;
+ if (i == 7)
+ return false;
+ if (s.s[pos + j] != ':') {
+ if (s.s[pos + j] != '.' || (i < 6 && !seen_colon))
+ return false;
+ return is_valid_ipv4((string_span_t){ s.s + pos, s.len - pos });
+ }
+ pos += j + 1;
+ }
+ return true;
+}
+
+static bool is_valid_uint(string_span_t s, bool support_hex, uint64_t min, uint64_t max)
+{
+ uint64_t val = 0;
+
+ /* Bound this around 32 bits, so that we don't have to write overflow logic. */
+ if (s.len > 10 || !s.len)
+ return false;
+
+ if (support_hex && s.len > 2 && s.s[0] == '0' && s.s[1] == 'x') {
+ for (size_t i = 2; i < s.len; ++i) {
+ if ((unsigned)s.s[i] - '0' < 10)
+ val = 16 * val + (s.s[i] - '0');
+ else if (((unsigned)s.s[i] | 32) - 'a' < 6)
+ val = 16 * val + (s.s[i] | 32) - 'a' + 10;
+ else
+ return false;
+ }
+ } else {
+ for (size_t i = 0; i < s.len; ++i) {
+ if (!is_decimal(s.s[i]))
+ return false;
+ val = 10 * val + s.s[i] - '0';
+ }
+ }
+ return val <= max && val >= min;
+}
+
+static bool is_valid_port(string_span_t s)
+{
+ return is_valid_uint(s, false, 0, 65535);
+}
+
+static bool is_valid_mtu(string_span_t s)
+{
+ return is_valid_uint(s, false, 576, 65535);
+}
+
+static bool is_valid_persistentkeepalive(string_span_t s)
+{
+ if (is_same(s, "off"))
+ return true;
+ return is_valid_uint(s, false, 0, 65535);
+}
+
+#ifndef MOBILE_WGQUICK_SUBSET
+
+static bool is_valid_fwmark(string_span_t s)
+{
+ if (is_same(s, "off"))
+ return true;
+ return is_valid_uint(s, true, 0, 4294967295);
+}
+
+static bool is_valid_table(string_span_t s)
+{
+ if (is_same(s, "auto"))
+ return true;
+ if (is_same(s, "off"))
+ return true;
+ /* This pretty much invalidates the other checks, but rt_names.c's
+ * fread_id_name does no validation aside from this. */
+ if (s.len < 512)
+ return true;
+ return is_valid_uint(s, false, 0, 4294967295);
+}
+
+static bool is_valid_saveconfig(string_span_t s)
+{
+ return is_same(s, "true") || is_same(s, "false");
+}
+
+static bool is_valid_prepostupdown(string_span_t s)
+{
+ /* It's probably not worthwhile to try to validate a bash expression.
+ * So instead we just demand non-zero length. */
+ return s.len;
+}
+#endif
+
+static bool is_valid_scope(string_span_t s)
+{
+ if (s.len > 64 || !s.len)
+ return false;
+ for (size_t i = 0; i < s.len; ++i) {
+ if (!is_alphabet(s.s[i]) && !is_decimal(s.s[i]) &&
+ s.s[i] != '_' && s.s[i] != '=' && s.s[i] != '+' &&
+ s.s[i] != '.' && s.s[i] != '-')
+ return false;
+ }
+ return true;
+}
+
+static bool is_valid_endpoint(string_span_t s)
+{
+
+ if (!s.len)
+ return false;
+
+ if (s.s[0] == '[') {
+ bool seen_scope = false;
+ string_span_t hostspan = { s.s + 1, 0 };
+
+ for (size_t i = 1; i < s.len; ++i) {
+ if (s.s[i] == '%') {
+ if (seen_scope)
+ return false;
+ seen_scope = true;
+ if (!is_valid_ipv6(hostspan))
+ return false;
+ hostspan = (string_span_t){ s.s + i + 1, 0 };
+ } else if (s.s[i] == ']') {
+ if (seen_scope) {
+ if (!is_valid_scope(hostspan))
+ return false;
+ } else if (!is_valid_ipv6(hostspan)) {
+ return false;
+ }
+ if (i == s.len - 1 || s.s[i + 1] != ':')
+ return false;
+ return is_valid_port((string_span_t){ s.s + i + 2, s.len - i - 2 });
+ } else {
+ ++hostspan.len;
+ }
+ }
+ return false;
+ }
+ for (size_t i = 0; i < s.len; ++i) {
+ if (s.s[i] == ':') {
+ string_span_t host = { s.s, i }, port = { s.s + i + 1, s.len - i - 1};
+ return is_valid_port(port) && (is_valid_ipv4(host) || is_valid_hostname(host));
+ }
+ }
+ return false;
+}
+
+static bool is_valid_network(string_span_t s)
+{
+ for (size_t i = 0; i < s.len; ++i) {
+ if (s.s[i] == '/') {
+ string_span_t ip = { s.s, i }, cidr = { s.s + i + 1, s.len - i - 1};
+ uint16_t cidrval = 0;
+
+ if (cidr.len > 3 || !cidr.len)
+ return false;
+
+ for (size_t j = 0; j < cidr.len; ++j) {
+ if (!is_decimal(cidr.s[j]))
+ return false;
+ cidrval = 10 * cidrval + cidr.s[j] - '0';
+ }
+ if (is_valid_ipv4(ip))
+ return cidrval <= 32;
+ else if (is_valid_ipv6(ip))
+ return cidrval <= 128;
+ return false;
+ }
+ }
+ return is_valid_ipv4(s) || is_valid_ipv6(s);
+}
+
+static bool is_valid_dns(string_span_t s)
+{
+ return is_valid_ipv4(s) || is_valid_ipv6(s);
+}
+
+enum field {
+ InterfaceSection,
+ PrivateKey,
+ ListenPort,
+ Address,
+ DNS,
+ MTU,
+#ifndef MOBILE_WGQUICK_SUBSET
+ FwMark,
+ Table,
+ PreUp, PostUp, PreDown, PostDown,
+ SaveConfig,
+#endif
+
+ PeerSection,
+ PublicKey,
+ PresharedKey,
+ AllowedIPs,
+ Endpoint,
+ PersistentKeepalive,
+
+ Invalid
+};
+
+static enum field section_for_field(enum field t)
+{
+ if (t > InterfaceSection && t < PeerSection)
+ return InterfaceSection;
+ if (t > PeerSection && t < Invalid)
+ return PeerSection;
+ return Invalid;
+}
+
+static enum field get_field(string_span_t s)
+{
+#define check_enum(t) do { if (is_caseless_same(s, #t)) return t; } while (0)
+ check_enum(PrivateKey);
+ check_enum(ListenPort);
+ check_enum(Address);
+ check_enum(DNS);
+ check_enum(MTU);
+ check_enum(PublicKey);
+ check_enum(PresharedKey);
+ check_enum(AllowedIPs);
+ check_enum(Endpoint);
+ check_enum(PersistentKeepalive);
+#ifndef MOBILE_WGQUICK_SUBSET
+ check_enum(FwMark);
+ check_enum(Table);
+ check_enum(PreUp);
+ check_enum(PostUp);
+ check_enum(PreDown);
+ check_enum(PostDown);
+ check_enum(SaveConfig);
+#endif
+ return Invalid;
+#undef check_enum
+}
+
+static enum field get_sectiontype(string_span_t s)
+{
+ if (is_caseless_same(s, "[Peer]"))
+ return PeerSection;
+ if (is_caseless_same(s, "[Interface]"))
+ return InterfaceSection;
+ return Invalid;
+}
+
+struct highlight_span_array {
+ size_t len, capacity;
+ struct highlight_span *spans;
+};
+
+/* A useful OpenBSD-ism. */
+static void *realloc_array(void *optr, size_t nmemb, size_t size)
+{
+ if ((nmemb >= (size_t)1 << (sizeof(size_t) * 4) ||
+ size >= (size_t)1 << (sizeof(size_t) * 4)) &&
+ nmemb > 0 && SIZE_MAX / nmemb < size) {
+ errno = ENOMEM;
+ return NULL;
+ }
+ return realloc(optr, size * nmemb);
+}
+
+static bool append_highlight_span(struct highlight_span_array *a, const char *o, string_span_t s, enum highlight_type t)
+{
+ if (!s.len)
+ return true;
+ if (a->len >= a->capacity) {
+ struct highlight_span *resized;
+
+ a->capacity = a->capacity ? a->capacity * 2 : 64;
+ resized = realloc_array(a->spans, a->capacity, sizeof(*resized));
+ if (!resized) {
+ free(a->spans);
+ memset(a, 0, sizeof(*a));
+ return false;
+ }
+ a->spans = resized;
+ }
+ a->spans[a->len++] = (struct highlight_span){ t, s.s - o, s.len };
+ return true;
+}
+
+static void highlight_multivalue_value(struct highlight_span_array *ret, const string_span_t parent, const string_span_t s, enum field section)
+{
+ switch (section) {
+ case DNS:
+ append_highlight_span(ret, parent.s, s, is_valid_dns(s) ? HighlightIP : HighlightError);
+ break;
+ case Address:
+ case AllowedIPs: {
+ size_t slash;
+
+ if (!is_valid_network(s)) {
+ append_highlight_span(ret, parent.s, s, HighlightError);
+ break;
+ }
+ for (slash = 0; slash < s.len; ++slash) {
+ if (s.s[slash] == '/')
+ break;
+ }
+ if (slash == s.len) {
+ append_highlight_span(ret, parent.s, s, HighlightIP);
+ } else {
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s, slash }, HighlightIP);
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s + slash, 1 }, HighlightDelimiter);
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s + slash + 1, s.len - slash - 1 }, HighlightCidr);
+ }
+ break;
+ }
+ default:
+ append_highlight_span(ret, parent.s, s, HighlightError);
+ }
+}
+
+static void highlight_multivalue(struct highlight_span_array *ret, const string_span_t parent, const string_span_t s, enum field section)
+{
+ string_span_t current_span = { s.s, 0 };
+ size_t len_at_last_space = 0;
+
+ for (size_t i = 0; i < s.len; ++i) {
+ if (s.s[i] == ',') {
+ current_span.len = len_at_last_space;
+ highlight_multivalue_value(ret, parent, current_span, section);
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s + i, 1 }, HighlightDelimiter);
+ len_at_last_space = 0;
+ current_span = (string_span_t){ s.s + i + 1, 0 };
+ } else if (s.s[i] == ' ' || s.s[i] == '\t') {
+ if (&s.s[i] == current_span.s && !current_span.len)
+ ++current_span.s;
+ else
+ ++current_span.len;
+ } else {
+ len_at_last_space = ++current_span.len;
+ }
+ }
+ current_span.len = len_at_last_space;
+ if (current_span.len)
+ highlight_multivalue_value(ret, parent, current_span, section);
+ else if (ret->spans[ret->len - 1].type == HighlightDelimiter)
+ ret->spans[ret->len - 1].type = HighlightError;
+}
+
+static void highlight_value(struct highlight_span_array *ret, const string_span_t parent, const string_span_t s, enum field section)
+{
+ switch (section) {
+ case PrivateKey:
+ append_highlight_span(ret, parent.s, s, is_valid_key(s) ? HighlightPrivateKey : HighlightError);
+ break;
+ case PublicKey:
+ append_highlight_span(ret, parent.s, s, is_valid_key(s) ? HighlightPublicKey : HighlightError);
+ break;
+ case PresharedKey:
+ append_highlight_span(ret, parent.s, s, is_valid_key(s) ? HighlightPresharedKey : HighlightError);
+ break;
+ case MTU:
+ append_highlight_span(ret, parent.s, s, is_valid_mtu(s) ? HighlightMTU : HighlightError);
+ break;
+#ifndef MOBILE_WGQUICK_SUBSET
+ case SaveConfig:
+ append_highlight_span(ret, parent.s, s, is_valid_saveconfig(s) ? HighlightSaveConfig : HighlightError);
+ break;
+ case FwMark:
+ append_highlight_span(ret, parent.s, s, is_valid_fwmark(s) ? HighlightFwMark : HighlightError);
+ break;
+ case Table:
+ append_highlight_span(ret, parent.s, s, is_valid_table(s) ? HighlightTable : HighlightError);
+ break;
+ case PreUp:
+ case PostUp:
+ case PreDown:
+ case PostDown:
+ append_highlight_span(ret, parent.s, s, is_valid_prepostupdown(s) ? HighlightCmd : HighlightError);
+ break;
+#endif
+ case ListenPort:
+ append_highlight_span(ret, parent.s, s, is_valid_port(s) ? HighlightPort : HighlightError);
+ break;
+ case PersistentKeepalive:
+ append_highlight_span(ret, parent.s, s, is_valid_persistentkeepalive(s) ? HighlightKeepalive : HighlightError);
+ break;
+ case Endpoint: {
+ size_t colon;
+
+ if (!is_valid_endpoint(s)) {
+ append_highlight_span(ret, parent.s, s, HighlightError);
+ break;
+ }
+ for (colon = s.len; colon --> 0;) {
+ if (s.s[colon] == ':')
+ break;
+ }
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s, colon }, HighlightHost);
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s + colon, 1 }, HighlightDelimiter);
+ append_highlight_span(ret, parent.s, (string_span_t){ s.s + colon + 1, s.len - colon - 1 }, HighlightPort);
+ break;
+ }
+ case Address:
+ case DNS:
+ case AllowedIPs:
+ highlight_multivalue(ret, parent, s, section);
+ break;
+ default:
+ append_highlight_span(ret, parent.s, s, HighlightError);
+ }
+}
+
+struct highlight_span *highlight_config(const char *config)
+{
+ struct highlight_span_array ret = { 0 };
+ const string_span_t s = { config, strlen(config) };
+ string_span_t current_span = { s.s, 0 };
+ enum field current_section = Invalid, current_field = Invalid;
+ enum { OnNone, OnKey, OnValue, OnComment, OnSection } state = OnNone;
+ size_t len_at_last_space = 0, equals_location = 0;
+
+ for (size_t i = 0; i <= s.len; ++i) {
+ if (i == s.len || s.s[i] == '\n' || (state != OnComment && s.s[i] == '#')) {
+ if (state == OnKey) {
+ current_span.len = len_at_last_space;
+ append_highlight_span(&ret, s.s, current_span, HighlightError);
+ } else if (state == OnValue) {
+ if (current_span.len) {
+ append_highlight_span(&ret, s.s, (string_span_t){ s.s + equals_location, 1 }, HighlightDelimiter);
+ current_span.len = len_at_last_space;
+ highlight_value(&ret, s, current_span, current_field);
+ } else {
+ append_highlight_span(&ret, s.s, (string_span_t){ s.s + equals_location, 1 }, HighlightError);
+ }
+ } else if (state == OnSection) {
+ current_span.len = len_at_last_space;
+ current_section = get_sectiontype(current_span);
+ append_highlight_span(&ret, s.s, current_span, current_section == Invalid ? HighlightError : HighlightSection);
+ } else if (state == OnComment) {
+ append_highlight_span(&ret, s.s, current_span, HighlightComment);
+ }
+ if (i == s.len)
+ break;
+ len_at_last_space = 0;
+ current_field = Invalid;
+ if (s.s[i] == '#') {
+ current_span = (string_span_t){ s.s + i, 1 };
+ state = OnComment;
+ } else {
+ current_span = (string_span_t){ s.s + i + 1, 0 };
+ state = OnNone;
+ }
+ } else if (state == OnComment) {
+ ++current_span.len;
+ } else if (s.s[i] == ' ' || s.s[i] == '\t') {
+ if (&s.s[i] == current_span.s && !current_span.len)
+ ++current_span.s;
+ else
+ ++current_span.len;
+ } else if (s.s[i] == '=' && state == OnKey) {
+ current_span.len = len_at_last_space;
+ current_field = get_field(current_span);
+ enum field section = section_for_field(current_field);
+ if (section == Invalid || current_field == Invalid || section != current_section)
+ append_highlight_span(&ret, s.s, current_span, HighlightError);
+ else
+ append_highlight_span(&ret, s.s, current_span, HighlightField);
+ equals_location = i;
+ current_span = (string_span_t){ s.s + i + 1, 0 };
+ state = OnValue;
+ } else {
+ if (state == OnNone)
+ state = s.s[i] == '[' ? OnSection : OnKey;
+ len_at_last_space = ++current_span.len;
+ }
+ }
+
+ append_highlight_span(&ret, s.s, (string_span_t){ s.s, -1 }, HighlightEnd);
+ return ret.spans;
+}
diff --git a/Sources/WireGuardApp/UI/macOS/View/highlighter.h b/Sources/WireGuardApp/UI/macOS/View/highlighter.h
new file mode 100644
index 0000000..885db2d
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/View/highlighter.h
@@ -0,0 +1,38 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
+ */
+
+#include <sys/types.h>
+#define MOBILE_WGQUICK_SUBSET
+
+enum highlight_type {
+ HighlightSection,
+ HighlightField,
+ HighlightPrivateKey,
+ HighlightPublicKey,
+ HighlightPresharedKey,
+ HighlightIP,
+ HighlightCidr,
+ HighlightHost,
+ HighlightPort,
+ HighlightMTU,
+ HighlightKeepalive,
+ HighlightComment,
+ HighlightDelimiter,
+#ifndef MOBILE_WGQUICK_SUBSET
+ HighlightTable,
+ HighlightFwMark,
+ HighlightSaveConfig,
+ HighlightCmd,
+#endif
+ HighlightError,
+ HighlightEnd
+};
+
+struct highlight_span {
+ enum highlight_type type;
+ size_t start, len;
+};
+
+struct highlight_span *highlight_config(const char *config);
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift
new file mode 100644
index 0000000..09c2414
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ButtonedDetailViewController: NSViewController {
+
+ var onButtonClicked: (() -> Void)?
+
+ let button: NSButton = {
+ let button = NSButton()
+ button.title = ""
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ init() {
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ let view = NSView()
+
+ button.target = self
+ button.action = #selector(buttonClicked)
+
+ view.addSubview(button)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ view.widthAnchor.constraint(greaterThanOrEqualToConstant: 320),
+ view.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+ ])
+
+ self.view = view
+ }
+
+ func setButtonTitle(_ title: String) {
+ button.title = title
+ }
+
+ @objc func buttonClicked() {
+ onButtonClicked?()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift
new file mode 100644
index 0000000..39ce663
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift
@@ -0,0 +1,274 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class LogViewController: NSViewController {
+
+ enum LogColumn: String {
+ case time = "Time"
+ case logMessage = "LogMessage"
+
+ func createColumn() -> NSTableColumn {
+ return NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue))
+ }
+
+ func isRepresenting(tableColumn: NSTableColumn?) -> Bool {
+ return tableColumn?.identifier.rawValue == rawValue
+ }
+ }
+
+ let scrollView: NSScrollView = {
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = false
+ scrollView.borderType = .bezelBorder
+ return scrollView
+ }()
+
+ let tableView: NSTableView = {
+ let tableView = NSTableView()
+ let timeColumn = LogColumn.time.createColumn()
+ timeColumn.title = tr("macLogColumnTitleTime")
+ timeColumn.width = 160
+ timeColumn.resizingMask = []
+ tableView.addTableColumn(timeColumn)
+ let messageColumn = LogColumn.logMessage.createColumn()
+ messageColumn.title = tr("macLogColumnTitleLogMessage")
+ messageColumn.minWidth = 360
+ messageColumn.resizingMask = .autoresizingMask
+ tableView.addTableColumn(messageColumn)
+ tableView.rowSizeStyle = .custom
+ tableView.rowHeight = 16
+ tableView.usesAlternatingRowBackgroundColors = true
+ tableView.usesAutomaticRowHeights = true
+ tableView.allowsColumnReordering = false
+ tableView.allowsColumnResizing = true
+ tableView.allowsMultipleSelection = true
+ return tableView
+ }()
+
+ let progressIndicator: NSProgressIndicator = {
+ let progressIndicator = NSProgressIndicator()
+ progressIndicator.controlSize = .small
+ progressIndicator.isIndeterminate = true
+ progressIndicator.style = .spinning
+ progressIndicator.isDisplayedWhenStopped = false
+ return progressIndicator
+ }()
+
+ let closeButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macLogButtonTitleClose")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ let saveButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macLogButtonTitleSave")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ let logViewHelper: LogViewHelper?
+ var logEntries = [LogViewHelper.LogEntry]()
+ var isFetchingLogEntries = false
+ var isInScrolledToEndMode = true
+
+ private var updateLogEntriesTimer: Timer?
+
+ init() {
+ logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path)
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ tableView.dataSource = self
+ tableView.delegate = self
+
+ closeButton.target = self
+ closeButton.action = #selector(closeClicked)
+
+ saveButton.target = self
+ saveButton.action = #selector(saveClicked)
+ saveButton.isEnabled = false
+
+ let clipView = NSClipView()
+ clipView.documentView = tableView
+ scrollView.contentView = clipView
+
+ _ = NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: clipView, queue: OperationQueue.main) { [weak self] _ in
+ guard let self = self else { return }
+ let lastVisibleRowIndex = self.tableView.row(at: NSPoint(x: 0, y: self.scrollView.contentView.documentVisibleRect.maxY - 1))
+ self.isInScrolledToEndMode = lastVisibleRowIndex < 0 || lastVisibleRowIndex == self.logEntries.count - 1
+ }
+
+ _ = NotificationCenter.default.addObserver(forName: NSView.frameDidChangeNotification, object: tableView, queue: OperationQueue.main) { [weak self] _ in
+ guard let self = self else { return }
+ if self.isInScrolledToEndMode {
+ DispatchQueue.main.async {
+ self.tableView.scroll(NSPoint(x: 0, y: self.tableView.frame.maxY - clipView.documentVisibleRect.height))
+ }
+ }
+ }
+
+ let margin: CGFloat = 20
+ let internalSpacing: CGFloat = 10
+
+ let buttonRowStackView = NSStackView()
+ buttonRowStackView.addView(closeButton, in: .leading)
+ buttonRowStackView.addView(saveButton, in: .trailing)
+ buttonRowStackView.orientation = .horizontal
+ buttonRowStackView.spacing = internalSpacing
+
+ let containerView = NSView()
+ [scrollView, progressIndicator, buttonRowStackView].forEach { view in
+ containerView.addSubview(view)
+ view.translatesAutoresizingMaskIntoConstraints = false
+ }
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: margin),
+ scrollView.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: margin),
+ containerView.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: margin),
+ buttonRowStackView.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: internalSpacing),
+ buttonRowStackView.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: margin),
+ containerView.rightAnchor.constraint(equalTo: buttonRowStackView.rightAnchor, constant: margin),
+ containerView.bottomAnchor.constraint(equalTo: buttonRowStackView.bottomAnchor, constant: margin),
+ progressIndicator.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
+ progressIndicator.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 640),
+ containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 1200),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 240)
+ ])
+
+ containerView.frame = NSRect(x: 0, y: 0, width: 640, height: 480)
+
+ view = containerView
+
+ progressIndicator.startAnimation(self)
+ startUpdatingLogEntries()
+ }
+
+ 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.progressIndicator.isHidden {
+ self.progressIndicator.stopAnimation(self)
+ self.saveButton.isEnabled = true
+ }
+ guard !fetchedLogEntries.isEmpty else { return }
+ let oldCount = self.logEntries.count
+ self.logEntries.append(contentsOf: fetchedLogEntries)
+ self.tableView.insertRows(at: IndexSet(integersIn: oldCount ..< oldCount + fetchedLogEntries.count), withAnimation: .slideDown)
+ }
+ }
+
+ 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)
+ }
+
+ func stopUpdatingLogEntries() {
+ updateLogEntriesTimer?.invalidate()
+ updateLogEntriesTimer = nil
+ }
+
+ override func viewWillAppear() {
+ view.window?.setFrameAutosaveName(NSWindow.FrameAutosaveName("LogWindow"))
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ stopUpdatingLogEntries()
+ }
+
+ @objc func saveClicked() {
+ let savePanel = NSSavePanel()
+ savePanel.prompt = tr("macSheetButtonExportLog")
+ savePanel.nameFieldLabel = tr("macNameFieldExportLog")
+
+ let dateFormatter = ISO8601DateFormatter()
+ dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename
+ let timeStampString = dateFormatter.string(from: Date())
+ savePanel.nameFieldStringValue = "wireguard-log-\(timeStampString).txt"
+
+ savePanel.beginSheetModal(for: self.view.window!) { [weak self] response in
+ guard response == .OK else { return }
+ guard let destinationURL = savePanel.url else { return }
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false
+ guard isWritten else {
+ DispatchQueue.main.async { [weak self] in
+ ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self)
+ }
+ return
+ }
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.presentingViewController?.dismiss(self)
+ }
+ }
+
+ }
+ }
+
+ @objc func closeClicked() {
+ presentingViewController?.dismiss(self)
+ }
+
+ @objc func copy(_ sender: Any?) {
+ let text = tableView.selectedRowIndexes.sorted().reduce("") { $0 + self.logEntries[$1].text() + "\n" }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.writeObjects([text as NSString])
+ }
+}
+
+extension LogViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return logEntries.count
+ }
+}
+
+extension LogViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ if LogColumn.time.isRepresenting(tableColumn: tableColumn) {
+ let cell: LogViewTimestampCell = tableView.dequeueReusableCell()
+ cell.text = logEntries[row].timestamp
+ return cell
+ } else if LogColumn.logMessage.isRepresenting(tableColumn: tableColumn) {
+ let cell: LogViewMessageCell = tableView.dequeueReusableCell()
+ cell.text = logEntries[row].message
+ return cell
+ } else {
+ fatalError()
+ }
+ }
+}
+
+extension LogViewController {
+ override func cancelOperation(_ sender: Any?) {
+ closeClicked()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift
new file mode 100644
index 0000000..0ad0805
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ManageTunnelsRootViewController: NSViewController {
+
+ let tunnelsManager: TunnelsManager
+ var tunnelsListVC: TunnelsListTableViewController?
+ var tunnelDetailVC: TunnelDetailTableViewController?
+ let tunnelDetailContainerView = NSView()
+ var tunnelDetailContentVC: NSViewController?
+
+ init(tunnelsManager: TunnelsManager) {
+ self.tunnelsManager = tunnelsManager
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ view = NSView()
+
+ let horizontalSpacing: CGFloat = 20
+ let verticalSpacing: CGFloat = 20
+ let centralSpacing: CGFloat = 10
+
+ let container = NSLayoutGuide()
+ view.addLayoutGuide(container)
+ NSLayoutConstraint.activate([
+ container.topAnchor.constraint(equalTo: view.topAnchor, constant: verticalSpacing),
+ view.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: verticalSpacing),
+ container.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: horizontalSpacing),
+ view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: horizontalSpacing)
+ ])
+
+ tunnelsListVC = TunnelsListTableViewController(tunnelsManager: tunnelsManager)
+ tunnelsListVC!.delegate = self
+ let tunnelsListView = tunnelsListVC!.view
+
+ addChild(tunnelsListVC!)
+ view.addSubview(tunnelsListView)
+ view.addSubview(tunnelDetailContainerView)
+
+ tunnelsListView.translatesAutoresizingMaskIntoConstraints = false
+ tunnelDetailContainerView.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ tunnelsListView.topAnchor.constraint(equalTo: container.topAnchor),
+ tunnelsListView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ tunnelsListView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ tunnelDetailContainerView.topAnchor.constraint(equalTo: container.topAnchor),
+ tunnelDetailContainerView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ tunnelDetailContainerView.leadingAnchor.constraint(equalTo: tunnelsListView.trailingAnchor, constant: centralSpacing),
+ tunnelDetailContainerView.trailingAnchor.constraint(equalTo: container.trailingAnchor)
+ ])
+ }
+
+ private func setTunnelDetailContentVC(_ contentVC: NSViewController) {
+ if let currentContentVC = tunnelDetailContentVC {
+ currentContentVC.view.removeFromSuperview()
+ currentContentVC.removeFromParent()
+ }
+ addChild(contentVC)
+ tunnelDetailContainerView.addSubview(contentVC.view)
+ contentVC.view.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ tunnelDetailContainerView.topAnchor.constraint(equalTo: contentVC.view.topAnchor),
+ tunnelDetailContainerView.bottomAnchor.constraint(equalTo: contentVC.view.bottomAnchor),
+ tunnelDetailContainerView.leadingAnchor.constraint(equalTo: contentVC.view.leadingAnchor),
+ tunnelDetailContainerView.trailingAnchor.constraint(equalTo: contentVC.view.trailingAnchor)
+ ])
+ tunnelDetailContentVC = contentVC
+ }
+}
+
+extension ManageTunnelsRootViewController: TunnelsListTableViewControllerDelegate {
+ func tunnelsSelected(tunnelIndices: [Int]) {
+ assert(!tunnelIndices.isEmpty)
+ if tunnelIndices.count == 1 {
+ let tunnel = tunnelsManager.tunnel(at: tunnelIndices.first!)
+ if tunnel.isTunnelAvailableToUser {
+ let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel)
+ setTunnelDetailContentVC(tunnelDetailVC)
+ self.tunnelDetailVC = tunnelDetailVC
+ } else {
+ let unusableTunnelDetailVC = tunnelDetailContentVC as? UnusableTunnelDetailViewController ?? UnusableTunnelDetailViewController()
+ unusableTunnelDetailVC.onButtonClicked = { [weak tunnelsListVC] in
+ tunnelsListVC?.handleRemoveTunnelAction()
+ }
+ setTunnelDetailContentVC(unusableTunnelDetailVC)
+ self.tunnelDetailVC = nil
+ }
+ } else if tunnelIndices.count > 1 {
+ let multiSelectionVC = tunnelDetailContentVC as? ButtonedDetailViewController ?? ButtonedDetailViewController()
+ multiSelectionVC.setButtonTitle(tr(format: "macButtonDeleteTunnels (%d)", tunnelIndices.count))
+ multiSelectionVC.onButtonClicked = { [weak tunnelsListVC] in
+ tunnelsListVC?.handleRemoveTunnelAction()
+ }
+ setTunnelDetailContentVC(multiSelectionVC)
+ self.tunnelDetailVC = nil
+ }
+ }
+
+ func tunnelsListEmpty() {
+ let noTunnelsVC = ButtonedDetailViewController()
+ noTunnelsVC.setButtonTitle(tr("macButtonImportTunnels"))
+ noTunnelsVC.onButtonClicked = { [weak self] in
+ guard let self = self else { return }
+ ImportPanelPresenter.presentImportPanel(tunnelsManager: self.tunnelsManager, sourceVC: self)
+ }
+ setTunnelDetailContentVC(noTunnelsVC)
+ self.tunnelDetailVC = nil
+ }
+}
+
+extension ManageTunnelsRootViewController {
+ override func supplementalTarget(forAction action: Selector, sender: Any?) -> Any? {
+ switch action {
+ case #selector(TunnelsListTableViewController.handleViewLogAction),
+ #selector(TunnelsListTableViewController.handleAddEmptyTunnelAction),
+ #selector(TunnelsListTableViewController.handleImportTunnelAction),
+ #selector(TunnelsListTableViewController.handleExportTunnelsAction),
+ #selector(TunnelsListTableViewController.handleRemoveTunnelAction):
+ return tunnelsListVC
+ case #selector(TunnelDetailTableViewController.handleToggleActiveStatusAction),
+ #selector(TunnelDetailTableViewController.handleEditTunnelAction):
+ return tunnelDetailVC
+ default:
+ return super.supplementalTarget(forAction: action, sender: sender)
+ }
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift
new file mode 100644
index 0000000..80b759e
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift
@@ -0,0 +1,516 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import WireGuardKit
+
+class TunnelDetailTableViewController: NSViewController {
+
+ private enum TableViewModelRow {
+ case interfaceFieldRow(TunnelViewModel.InterfaceField)
+ case peerFieldRow(peer: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField)
+ case onDemandRow
+ case onDemandSSIDRow
+ case spacerRow
+
+ func localizedSectionKeyString() -> String {
+ switch self {
+ case .interfaceFieldRow: return tr("tunnelSectionTitleInterface")
+ case .peerFieldRow: return tr("tunnelSectionTitlePeer")
+ case .onDemandRow: return tr("macFieldOnDemand")
+ case .onDemandSSIDRow: return ""
+ case .spacerRow: return ""
+ }
+ }
+
+ func isTitleRow() -> Bool {
+ switch self {
+ case .interfaceFieldRow(let field): return field == .name
+ case .peerFieldRow(_, let field): return field == .publicKey
+ case .onDemandRow: return true
+ case .onDemandSSIDRow: return false
+ case .spacerRow: return false
+ }
+ }
+ }
+
+ static let interfaceFields: [TunnelViewModel.InterfaceField] = [
+ .name, .status, .publicKey, .addresses,
+ .listenPort, .mtu, .dns, .toggleStatus
+ ]
+
+ static let peerFields: [TunnelViewModel.PeerField] = [
+ .publicKey, .preSharedKey, .endpoint,
+ .allowedIPs, .persistentKeepAlive,
+ .rxBytes, .txBytes, .lastHandshakeTime
+ ]
+
+ static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
+ .onDemand, .ssid
+ ]
+
+ let tableView: NSTableView = {
+ let tableView = NSTableView()
+ tableView.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TunnelDetail")))
+ tableView.headerView = nil
+ tableView.rowSizeStyle = .medium
+ tableView.backgroundColor = .clear
+ tableView.selectionHighlightStyle = .none
+ return tableView
+ }()
+
+ let editButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macButtonEdit")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ button.toolTip = tr("macToolTipEditTunnel")
+ return button
+ }()
+
+ let box: NSBox = {
+ let box = NSBox()
+ box.titlePosition = .noTitle
+ box.fillColor = .unemphasizedSelectedContentBackgroundColor
+ return box
+ }()
+
+ let tunnelsManager: TunnelsManager
+ let tunnel: TunnelContainer
+
+ var tunnelViewModel: TunnelViewModel {
+ didSet {
+ updateTableViewModelRowsBySection()
+ updateTableViewModelRows()
+ }
+ }
+
+ var onDemandViewModel: ActivateOnDemandViewModel
+
+ private var tableViewModelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
+ private var tableViewModelRows = [TableViewModelRow]()
+
+ private var statusObservationToken: AnyObject?
+ private var tunnelEditVC: TunnelEditViewController?
+ 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(nibName: nil, bundle: nil)
+ updateTableViewModelRowsBySection()
+ updateTableViewModelRows()
+ statusObservationToken = tunnel.observe(\TunnelContainer.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()
+ }
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ tableView.dataSource = self
+ tableView.delegate = self
+
+ editButton.target = self
+ editButton.action = #selector(handleEditTunnelAction)
+
+ let clipView = NSClipView()
+ clipView.documentView = tableView
+
+ let scrollView = NSScrollView()
+ scrollView.contentView = clipView // Set contentView before setting drawsBackground
+ scrollView.drawsBackground = false
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+
+ let containerView = NSView()
+ let bottomControlsContainer = NSLayoutGuide()
+ containerView.addLayoutGuide(bottomControlsContainer)
+ containerView.addSubview(box)
+ containerView.addSubview(scrollView)
+ containerView.addSubview(editButton)
+ box.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ editButton.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ containerView.topAnchor.constraint(equalTo: scrollView.topAnchor),
+ containerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
+ containerView.leadingAnchor.constraint(equalTo: bottomControlsContainer.leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: bottomControlsContainer.trailingAnchor),
+ bottomControlsContainer.heightAnchor.constraint(equalToConstant: 32),
+ scrollView.bottomAnchor.constraint(equalTo: bottomControlsContainer.topAnchor),
+ bottomControlsContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ editButton.trailingAnchor.constraint(equalTo: bottomControlsContainer.trailingAnchor),
+ bottomControlsContainer.bottomAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 0)
+ ])
+
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: box.topAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: box.bottomAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: box.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: box.trailingAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 320),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+ ])
+
+ view = containerView
+ }
+
+ func updateTableViewModelRowsBySection() {
+ var modelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
+
+ var interfaceSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
+ for field in TunnelDetailTableViewController.interfaceFields {
+ let isStatus = field == .status || field == .toggleStatus
+ let isEmpty = tunnelViewModel.interfaceData[field].isEmpty
+ interfaceSection.append((isVisible: isStatus || !isEmpty, modelRow: .interfaceFieldRow(field)))
+ }
+ interfaceSection.append((isVisible: true, modelRow: .spacerRow))
+ modelRowsBySection.append(interfaceSection)
+
+ for peerData in tunnelViewModel.peersData {
+ var peerSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
+ for field in TunnelDetailTableViewController.peerFields {
+ peerSection.append((isVisible: !peerData[field].isEmpty, modelRow: .peerFieldRow(peer: peerData, field: field)))
+ }
+ peerSection.append((isVisible: true, modelRow: .spacerRow))
+ modelRowsBySection.append(peerSection)
+ }
+
+ var onDemandSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
+ onDemandSection.append((isVisible: true, modelRow: .onDemandRow))
+ if onDemandViewModel.isWiFiInterfaceEnabled {
+ onDemandSection.append((isVisible: true, modelRow: .onDemandSSIDRow))
+ }
+ modelRowsBySection.append(onDemandSection)
+
+ tableViewModelRowsBySection = modelRowsBySection
+ }
+
+ func updateTableViewModelRows() {
+ tableViewModelRows = tableViewModelRowsBySection.flatMap { $0.filter { $0.isVisible }.map { $0.modelRow } }
+ }
+
+ @objc func handleEditTunnelAction() {
+ PrivateDataConfirmation.confirmAccess(to: tr("macViewPrivateData")) { [weak self] in
+ guard let self = self else { return }
+ let tunnelEditVC = TunnelEditViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel)
+ tunnelEditVC.delegate = self
+ self.presentAsSheet(tunnelEditVC)
+ self.tunnelEditVC = tunnelEditVC
+ }
+ }
+
+ @objc func handleToggleActiveStatusAction() {
+ if tunnel.status == .inactive {
+ tunnelsManager.startActivation(of: tunnel)
+ } else if tunnel.status == .active {
+ tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+
+ override func viewWillAppear() {
+ if tunnel.status == .active {
+ startUpdatingRuntimeConfiguration()
+ }
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ if let tunnelEditVC = tunnelEditVC {
+ dismiss(tunnelEditVC)
+ }
+ stopUpdatingRuntimeConfiguration()
+ }
+
+ func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) {
+ // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering.
+
+ let tableView = self.tableView
+
+ func handleSectionFieldsModified<T>(fields: [T], modelRowsInSection: [(isVisible: Bool, modelRow: TableViewModelRow)], rowOffset: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
+ var modifiedRowIndices = IndexSet()
+ for (index, field) in fields.enumerated() {
+ guard let change = changes[field] else { continue }
+ if case .modified(_) = change {
+ let row = modelRowsInSection[0 ..< index].filter { $0.isVisible }.count
+ modifiedRowIndices.insert(rowOffset + row)
+ }
+ }
+ if !modifiedRowIndices.isEmpty {
+ tableView.reloadData(forRowIndexes: modifiedRowIndices, columnIndexes: IndexSet(integer: 0))
+ }
+ }
+
+ func handleSectionFieldsAddedOrRemoved<T>(fields: [T], modelRowsInSection: inout [(isVisible: Bool, modelRow: TableViewModelRow)], rowOffset: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
+ for (index, field) in fields.enumerated() {
+ guard let change = changes[field] else { continue }
+ let row = modelRowsInSection[0 ..< index].filter { $0.isVisible }.count
+ switch change {
+ case .added:
+ tableView.insertRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
+ modelRowsInSection[index].isVisible = true
+ case .removed:
+ tableView.removeRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
+ modelRowsInSection[index].isVisible = false
+ case .modified:
+ break
+ }
+ }
+ }
+
+ let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration)
+
+ if !changes.interfaceChanges.isEmpty {
+ handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields,
+ modelRowsInSection: self.tableViewModelRowsBySection[0],
+ rowOffset: 0, changes: changes.interfaceChanges)
+ }
+ for (peerIndex, peerChanges) in changes.peerChanges {
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields,
+ modelRowsInSection: self.tableViewModelRowsBySection[sectionIndex],
+ rowOffset: rowOffset, 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 } }
+
+ if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !changes.peersRemovedIndices.isEmpty || !changes.peersInsertedIndices.isEmpty {
+ tableView.beginUpdates()
+ if isAnyInterfaceFieldAddedOrRemoved {
+ handleSectionFieldsAddedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields,
+ modelRowsInSection: &self.tableViewModelRowsBySection[0],
+ rowOffset: 0, changes: changes.interfaceChanges)
+ }
+ if isAnyPeerFieldAddedOrRemoved {
+ for (peerIndex, peerChanges) in changes.peerChanges {
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ handleSectionFieldsAddedOrRemoved(fields: TunnelDetailTableViewController.peerFields, modelRowsInSection: &self.tableViewModelRowsBySection[sectionIndex], rowOffset: rowOffset, changes: peerChanges)
+ }
+ }
+ if !changes.peersRemovedIndices.isEmpty {
+ for peerIndex in changes.peersRemovedIndices {
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ let count = self.tableViewModelRowsBySection[sectionIndex].filter { $0.isVisible }.count
+ self.tableView.removeRows(at: IndexSet(integersIn: rowOffset ..< rowOffset + count), withAnimation: .effectFade)
+ self.tableViewModelRowsBySection.remove(at: sectionIndex)
+ }
+ }
+ if !changes.peersInsertedIndices.isEmpty {
+ for peerIndex in changes.peersInsertedIndices {
+ let peerData = self.tunnelViewModel.peersData[peerIndex]
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ var modelRowsInSection: [(isVisible: Bool, modelRow: TableViewModelRow)] = TunnelDetailTableViewController.peerFields.map {
+ (isVisible: !peerData[$0].isEmpty, modelRow: .peerFieldRow(peer: peerData, field: $0))
+ }
+ modelRowsInSection.append((isVisible: true, modelRow: .spacerRow))
+ let count = modelRowsInSection.filter { $0.isVisible }.count
+ self.tableView.insertRows(at: IndexSet(integersIn: rowOffset ..< rowOffset + count), withAnimation: .effectFade)
+ self.tableViewModelRowsBySection.insert(modelRowsInSection, at: sectionIndex)
+ }
+ }
+ updateTableViewModelRows()
+ tableView.endUpdates()
+ }
+ }
+
+ private func reloadRuntimeConfiguration() {
+ tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in
+ guard let tunnelConfiguration = tunnelConfiguration else { return }
+ self?.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration)
+ }
+ }
+
+ 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() {
+ reloadRuntimeConfiguration()
+ reloadRuntimeConfigurationTimer?.invalidate()
+ reloadRuntimeConfigurationTimer = nil
+ }
+
+}
+
+extension TunnelDetailTableViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return tableViewModelRows.count
+ }
+}
+
+extension TunnelDetailTableViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ let modelRow = tableViewModelRows[row]
+ switch modelRow {
+ case .interfaceFieldRow(let field):
+ if field == .status {
+ return statusCell()
+ } else if field == .toggleStatus {
+ return toggleStatusCell()
+ } else {
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ let localizedKeyString = modelRow.isTitleRow() ? modelRow.localizedSectionKeyString() : field.localizedUIString
+ cell.key = tr(format: "macFieldKey (%@)", localizedKeyString)
+ cell.value = tunnelViewModel.interfaceData[field]
+ cell.isKeyInBold = modelRow.isTitleRow()
+ return cell
+ }
+ case .peerFieldRow(let peerData, let field):
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ let localizedKeyString = modelRow.isTitleRow() ? modelRow.localizedSectionKeyString() : field.localizedUIString
+ cell.key = tr(format: "macFieldKey (%@)", localizedKeyString)
+ if field == .persistentKeepAlive {
+ cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field])
+ } else if field == .preSharedKey {
+ cell.value = tr("tunnelPeerPresharedKeyEnabled")
+ } else {
+ cell.value = peerData[field]
+ }
+ cell.isKeyInBold = modelRow.isTitleRow()
+ return cell
+ case .spacerRow:
+ return NSView()
+ case .onDemandRow:
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ cell.key = modelRow.localizedSectionKeyString()
+ cell.value = onDemandViewModel.localizedInterfaceDescription
+ cell.isKeyInBold = true
+ return cell
+ case .onDemandSSIDRow:
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ cell.key = tr("macFieldOnDemandSSIDs")
+ let value: String
+ if onDemandViewModel.ssidOption == .anySSID {
+ value = onDemandViewModel.ssidOption.localizedUIString
+ } else {
+ value = tr(format: "tunnelOnDemandSSIDOptionDescriptionMac (%1$@: %2$@)",
+ onDemandViewModel.ssidOption.localizedUIString,
+ onDemandViewModel.selectedSSIDs.joined(separator: ", "))
+ }
+ cell.value = value
+ cell.isKeyInBold = false
+ return cell
+ }
+ }
+
+ func statusCell() -> NSView {
+ let cell: KeyValueImageRow = tableView.dequeueReusableCell()
+ cell.key = tr(format: "macFieldKey (%@)", tr("tunnelInterfaceStatus"))
+ cell.value = TunnelDetailTableViewController.localizedStatusDescription(forStatus: tunnel.status)
+ cell.valueImage = TunnelDetailTableViewController.image(forStatus: tunnel.status)
+ cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
+ guard let cell = cell else { return }
+ cell.value = TunnelDetailTableViewController.localizedStatusDescription(forStatus: tunnel.status)
+ cell.valueImage = TunnelDetailTableViewController.image(forStatus: tunnel.status)
+ }
+ return cell
+ }
+
+ func toggleStatusCell() -> NSView {
+ let cell: ButtonRow = tableView.dequeueReusableCell()
+ cell.buttonTitle = TunnelDetailTableViewController.localizedToggleStatusActionText(forStatus: tunnel.status)
+ cell.isButtonEnabled = (tunnel.status == .active || tunnel.status == .inactive)
+ cell.buttonToolTip = tr("macToolTipToggleStatus")
+ cell.onButtonClicked = { [weak self] in
+ self?.handleToggleActiveStatusAction()
+ }
+ cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
+ guard let cell = cell else { return }
+ cell.buttonTitle = TunnelDetailTableViewController.localizedToggleStatusActionText(forStatus: tunnel.status)
+ cell.isButtonEnabled = (tunnel.status == .active || tunnel.status == .inactive)
+ }
+ return cell
+ }
+
+ private static func localizedStatusDescription(forStatus status: TunnelStatus) -> String {
+ switch status {
+ case .inactive:
+ return tr("tunnelStatusInactive")
+ case .activating:
+ return tr("tunnelStatusActivating")
+ case .active:
+ return tr("tunnelStatusActive")
+ case .deactivating:
+ return tr("tunnelStatusDeactivating")
+ case .reasserting:
+ return tr("tunnelStatusReasserting")
+ case .restarting:
+ return tr("tunnelStatusRestarting")
+ case .waiting:
+ return tr("tunnelStatusWaiting")
+ }
+ }
+
+ private static func image(forStatus status: TunnelStatus?) -> NSImage? {
+ guard let status = status else { return nil }
+ switch status {
+ case .active, .restarting, .reasserting:
+ return NSImage(named: NSImage.statusAvailableName)
+ case .activating, .waiting, .deactivating:
+ return NSImage(named: NSImage.statusPartiallyAvailableName)
+ case .inactive:
+ return NSImage(named: NSImage.statusNoneName)
+ }
+ }
+
+ private static func localizedToggleStatusActionText(forStatus status: TunnelStatus) -> String {
+ switch status {
+ case .waiting:
+ return tr("macToggleStatusButtonWaiting")
+ case .inactive:
+ return tr("macToggleStatusButtonActivate")
+ case .activating:
+ return tr("macToggleStatusButtonActivating")
+ case .active:
+ return tr("macToggleStatusButtonDeactivate")
+ case .deactivating:
+ return tr("macToggleStatusButtonDeactivating")
+ case .reasserting:
+ return tr("macToggleStatusButtonReasserting")
+ case .restarting:
+ return tr("macToggleStatusButtonRestarting")
+ }
+ }
+}
+
+extension TunnelDetailTableViewController: TunnelEditViewControllerDelegate {
+ func tunnelSaved(tunnel: TunnelContainer) {
+ tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
+ onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
+ updateTableViewModelRowsBySection()
+ updateTableViewModelRows()
+ tableView.reloadData()
+ self.tunnelEditVC = nil
+ }
+
+ func tunnelEditingCancelled() {
+ self.tunnelEditVC = nil
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift
new file mode 100644
index 0000000..97eaf8f
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift
@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import WireGuardKit
+
+protocol TunnelEditViewControllerDelegate: class {
+ func tunnelSaved(tunnel: TunnelContainer)
+ func tunnelEditingCancelled()
+}
+
+class TunnelEditViewController: NSViewController {
+
+ let nameRow: EditableKeyValueRow = {
+ let nameRow = EditableKeyValueRow()
+ nameRow.key = tr(format: "macFieldKey (%@)", TunnelViewModel.InterfaceField.name.localizedUIString)
+ return nameRow
+ }()
+
+ let publicKeyRow: KeyValueRow = {
+ let publicKeyRow = KeyValueRow()
+ publicKeyRow.key = tr(format: "macFieldKey (%@)", TunnelViewModel.InterfaceField.publicKey.localizedUIString)
+ return publicKeyRow
+ }()
+
+ let textView: ConfTextView = {
+ let textView = ConfTextView()
+ let minWidth: CGFloat = 120
+ let minHeight: CGFloat = 0
+ textView.minSize = NSSize(width: 0, height: minHeight)
+ textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
+ textView.autoresizingMask = [.width] // Width should be based on superview width
+ textView.isHorizontallyResizable = false // Width shouldn't be based on content
+ textView.isVerticallyResizable = true // Height should be based on content
+ if let textContainer = textView.textContainer {
+ textContainer.size = NSSize(width: minWidth, height: CGFloat.greatestFiniteMagnitude)
+ textContainer.widthTracksTextView = true
+ }
+ NSLayoutConstraint.activate([
+ textView.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth),
+ textView.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
+ ])
+ return textView
+ }()
+
+ let onDemandControlsRow = OnDemandControlsRow()
+
+ let scrollView: NSScrollView = {
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+ scrollView.borderType = .bezelBorder
+ return scrollView
+ }()
+
+ let excludePrivateIPsCheckbox: NSButton = {
+ let checkbox = NSButton()
+ checkbox.title = tr("tunnelPeerExcludePrivateIPs")
+ checkbox.setButtonType(.switch)
+ checkbox.state = .off
+ return checkbox
+ }()
+
+ let discardButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macEditDiscard")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ let saveButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macEditSave")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ button.keyEquivalent = "s"
+ button.keyEquivalentModifierMask = [.command]
+ return button
+ }()
+
+ let tunnelsManager: TunnelsManager
+ let tunnel: TunnelContainer?
+ var onDemandViewModel: ActivateOnDemandViewModel
+
+ weak var delegate: TunnelEditViewControllerDelegate?
+
+ var privateKeyObservationToken: AnyObject?
+ var hasErrorObservationToken: AnyObject?
+ var singlePeerAllowedIPsObservationToken: AnyObject?
+
+ var dnsServersAddedToAllowedIPs: String?
+
+ init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer?) {
+ self.tunnelsManager = tunnelsManager
+ self.tunnel = tunnel
+ self.onDemandViewModel = tunnel != nil ? ActivateOnDemandViewModel(tunnel: tunnel!) : ActivateOnDemandViewModel()
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func populateFields() {
+ if let tunnel = tunnel {
+ // Editing an existing tunnel
+ let tunnelConfiguration = tunnel.tunnelConfiguration!
+ nameRow.value = tunnel.name
+ textView.string = tunnelConfiguration.asWgQuickConfig()
+ publicKeyRow.value = tunnelConfiguration.interface.privateKey.publicKey.base64Key
+ textView.privateKeyString = tunnelConfiguration.interface.privateKey.base64Key
+ let singlePeer = tunnelConfiguration.peers.count == 1 ? tunnelConfiguration.peers.first : nil
+ updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: singlePeer?.allowedIPs.map { $0.stringRepresentation })
+ dnsServersAddedToAllowedIPs = excludePrivateIPsCheckbox.state == .on ? tunnelConfiguration.interface.dns.map { $0.stringRepresentation }.joined(separator: ", ") : nil
+ } else {
+ // Creating a new tunnel
+ let privateKey = PrivateKey()
+ let bootstrappingText = "[Interface]\nPrivateKey = \(privateKey.base64Key)\n"
+ publicKeyRow.value = privateKey.publicKey.base64Key
+ textView.string = bootstrappingText
+ updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: nil)
+ dnsServersAddedToAllowedIPs = nil
+ }
+ privateKeyObservationToken = textView.observe(\.privateKeyString) { [weak publicKeyRow] textView, _ in
+ if let privateKeyString = textView.privateKeyString,
+ let privateKey = PrivateKey(base64Key: privateKeyString) {
+ publicKeyRow?.value = privateKey.publicKey.base64Key
+ } else {
+ publicKeyRow?.value = ""
+ }
+ }
+ hasErrorObservationToken = textView.observe(\.hasError) { [weak saveButton] textView, _ in
+ saveButton?.isEnabled = !textView.hasError
+ }
+ singlePeerAllowedIPsObservationToken = textView.observe(\.singlePeerAllowedIPs) { [weak self] textView, _ in
+ self?.updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: textView.singlePeerAllowedIPs)
+ }
+ }
+
+ override func loadView() {
+ populateFields()
+
+ scrollView.documentView = textView
+
+ saveButton.target = self
+ saveButton.action = #selector(handleSaveAction)
+
+ discardButton.target = self
+ discardButton.action = #selector(handleDiscardAction)
+
+ excludePrivateIPsCheckbox.target = self
+ excludePrivateIPsCheckbox.action = #selector(excludePrivateIPsCheckboxToggled(sender:))
+
+ onDemandControlsRow.onDemandViewModel = onDemandViewModel
+
+ let margin: CGFloat = 20
+ let internalSpacing: CGFloat = 10
+
+ let editorStackView = NSStackView(views: [nameRow, publicKeyRow, onDemandControlsRow, scrollView])
+ editorStackView.orientation = .vertical
+ editorStackView.setHuggingPriority(.defaultHigh, for: .horizontal)
+ editorStackView.spacing = internalSpacing
+
+ let buttonRowStackView = NSStackView()
+ buttonRowStackView.setViews([discardButton, saveButton], in: .trailing)
+ buttonRowStackView.addView(excludePrivateIPsCheckbox, in: .leading)
+ buttonRowStackView.orientation = .horizontal
+ buttonRowStackView.spacing = internalSpacing
+
+ let containerView = NSStackView(views: [editorStackView, buttonRowStackView])
+ containerView.orientation = .vertical
+ containerView.edgeInsets = NSEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)
+ containerView.setHuggingPriority(.defaultHigh, for: .horizontal)
+ containerView.spacing = internalSpacing
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 240)
+ ])
+ containerView.frame = NSRect(x: 0, y: 0, width: 600, height: 480)
+
+ self.view = containerView
+ }
+
+ func setUserInteractionEnabled(_ enabled: Bool) {
+ view.window?.ignoresMouseEvents = !enabled
+ nameRow.valueLabel.isEditable = enabled
+ textView.isEditable = enabled
+ onDemandControlsRow.onDemandSSIDsField.isEnabled = enabled
+ }
+
+ @objc func handleSaveAction() {
+ let name = nameRow.value
+ guard !name.isEmpty else {
+ ErrorPresenter.showErrorAlert(title: tr("macAlertNameIsEmpty"), message: "", from: self)
+ return
+ }
+
+ onDemandControlsRow.saveToViewModel()
+ let onDemandOption = onDemandViewModel.toOnDemandOption()
+
+ let isTunnelModifiedWithoutChangingName = (tunnel != nil && tunnel!.name == name)
+ guard isTunnelModifiedWithoutChangingName || tunnelsManager.tunnel(named: name) == nil else {
+ ErrorPresenter.showErrorAlert(title: tr(format: "macAlertDuplicateName (%@)", name), message: "", from: self)
+ return
+ }
+
+ var tunnelConfiguration: TunnelConfiguration
+ do {
+ tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: textView.string, called: nameRow.value)
+ } catch let error as WireGuardAppError {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ } catch {
+ fatalError()
+ }
+
+ if excludePrivateIPsCheckbox.state == .on, tunnelConfiguration.peers.count == 1, let dnsServersAddedToAllowedIPs = dnsServersAddedToAllowedIPs {
+ // Update the DNS servers in the AllowedIPs
+ let tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration)
+ let originalAllowedIPs = tunnelViewModel.peersData[0][.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
+ let dnsServersInAllowedIPs = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(dnsServersAddedToAllowedIPs.splitToArray(trimmingCharacters: .whitespacesAndNewlines))
+ let dnsServersCurrent = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(tunnelViewModel.interfaceData[.dns].splitToArray(trimmingCharacters: .whitespacesAndNewlines))
+ let modifiedAllowedIPs = originalAllowedIPs.filter { !dnsServersInAllowedIPs.contains($0) } + dnsServersCurrent
+ tunnelViewModel.peersData[0][.allowedIPs] = modifiedAllowedIPs.joined(separator: ", ")
+ let saveResult = tunnelViewModel.save()
+ if case .saved(let modifiedTunnelConfiguration) = saveResult {
+ tunnelConfiguration = modifiedTunnelConfiguration
+ }
+ }
+
+ setUserInteractionEnabled(false)
+
+ if let tunnel = tunnel {
+ // We're modifying an existing tunnel
+ tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] error in
+ guard let self = self else { return }
+ self.setUserInteractionEnabled(true)
+ if let error = error {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ }
+ self.delegate?.tunnelSaved(tunnel: tunnel)
+ self.presentingViewController?.dismiss(self)
+ }
+ } else {
+ // We're creating a new tunnel
+ self.tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] result in
+ guard let self = self else { return }
+ self.setUserInteractionEnabled(true)
+ switch result {
+ case .failure(let error):
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ case .success(let tunnel):
+ self.delegate?.tunnelSaved(tunnel: tunnel)
+ self.presentingViewController?.dismiss(self)
+ }
+ }
+ }
+ }
+
+ @objc func handleDiscardAction() {
+ delegate?.tunnelEditingCancelled()
+ presentingViewController?.dismiss(self)
+ }
+
+ func updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: [String]?) {
+ let shouldAllowExcludePrivateIPsControl: Bool
+ let excludePrivateIPsValue: Bool
+ if let singlePeerAllowedIPs = singlePeerAllowedIPs {
+ (shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: true, allowedIPs: Set<String>(singlePeerAllowedIPs))
+ } else {
+ (shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: false, allowedIPs: Set<String>())
+ }
+ excludePrivateIPsCheckbox.isHidden = !shouldAllowExcludePrivateIPsControl
+ excludePrivateIPsCheckbox.state = excludePrivateIPsValue ? .on : .off
+ }
+
+ @objc func excludePrivateIPsCheckboxToggled(sender: AnyObject?) {
+ guard let excludePrivateIPsCheckbox = sender as? NSButton else { return }
+ guard let tunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: textView.string, called: nameRow.value) else { return }
+ let isOn = excludePrivateIPsCheckbox.state == .on
+ let tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration)
+ tunnelViewModel.peersData.first?.excludePrivateIPsValueChanged(isOn: isOn, dnsServers: tunnelViewModel.interfaceData[.dns], oldDNSServers: dnsServersAddedToAllowedIPs)
+ if let modifiedConfig = tunnelViewModel.asWgQuickConfig() {
+ textView.setConfText(modifiedConfig)
+ dnsServersAddedToAllowedIPs = isOn ? tunnelViewModel.interfaceData[.dns] : nil
+ }
+ }
+}
+
+extension TunnelEditViewController {
+ override func cancelOperation(_ sender: Any?) {
+ handleDiscardAction()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
new file mode 100644
index 0000000..0771582
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
@@ -0,0 +1,354 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+protocol TunnelsListTableViewControllerDelegate: class {
+ func tunnelsSelected(tunnelIndices: [Int])
+ func tunnelsListEmpty()
+}
+
+class TunnelsListTableViewController: NSViewController {
+
+ let tunnelsManager: TunnelsManager
+ weak var delegate: TunnelsListTableViewControllerDelegate?
+ var isRemovingTunnelsFromWithinTheApp = false
+
+ let tableView: NSTableView = {
+ let tableView = NSTableView()
+ tableView.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TunnelsList")))
+ tableView.headerView = nil
+ tableView.rowSizeStyle = .medium
+ tableView.allowsMultipleSelection = true
+ return tableView
+ }()
+
+ let addButton: NSPopUpButton = {
+ let imageItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ imageItem.image = NSImage(named: NSImage.addTemplateName)!
+
+ let menu = NSMenu()
+ menu.addItem(imageItem)
+ menu.addItem(withTitle: tr("macMenuAddEmptyTunnel"), action: #selector(handleAddEmptyTunnelAction), keyEquivalent: "n")
+ menu.addItem(withTitle: tr("macMenuImportTunnels"), action: #selector(handleImportTunnelAction), keyEquivalent: "o")
+ menu.autoenablesItems = false
+
+ let button = NSPopUpButton(frame: NSRect.zero, pullsDown: true)
+ button.menu = menu
+ button.bezelStyle = .smallSquare
+ (button.cell as? NSPopUpButtonCell)?.arrowPosition = .arrowAtBottom
+ return button
+ }()
+
+ let removeButton: NSButton = {
+ let image = NSImage(named: NSImage.removeTemplateName)!
+ let button = NSButton(image: image, target: self, action: #selector(handleRemoveTunnelAction))
+ button.bezelStyle = .smallSquare
+ button.imagePosition = .imageOnly
+ return button
+ }()
+
+ let actionButton: NSPopUpButton = {
+ let imageItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ imageItem.image = NSImage(named: NSImage.actionTemplateName)!
+
+ let menu = NSMenu()
+ menu.addItem(imageItem)
+ menu.addItem(withTitle: tr("macMenuViewLog"), action: #selector(handleViewLogAction), keyEquivalent: "")
+ menu.addItem(withTitle: tr("macMenuExportTunnels"), action: #selector(handleExportTunnelsAction), keyEquivalent: "")
+ menu.autoenablesItems = false
+
+ let button = NSPopUpButton(frame: NSRect.zero, pullsDown: true)
+ button.menu = menu
+ button.bezelStyle = .smallSquare
+ (button.cell as? NSPopUpButtonCell)?.arrowPosition = .arrowAtBottom
+ return button
+ }()
+
+ init(tunnelsManager: TunnelsManager) {
+ self.tunnelsManager = tunnelsManager
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ tableView.dataSource = self
+ tableView.delegate = self
+
+ tableView.doubleAction = #selector(listDoubleClicked(sender:))
+
+ let isSelected = selectTunnelInOperation() || selectTunnel(at: 0)
+ if !isSelected {
+ delegate?.tunnelsListEmpty()
+ }
+ tableView.allowsEmptySelection = false
+
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+ scrollView.borderType = .bezelBorder
+
+ let clipView = NSClipView()
+ clipView.documentView = tableView
+ scrollView.contentView = clipView
+
+ let buttonBar = NSStackView(views: [addButton, removeButton, actionButton])
+ buttonBar.orientation = .horizontal
+ buttonBar.spacing = -1
+
+ NSLayoutConstraint.activate([
+ removeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 26),
+ removeButton.topAnchor.constraint(equalTo: buttonBar.topAnchor),
+ removeButton.bottomAnchor.constraint(equalTo: buttonBar.bottomAnchor)
+ ])
+
+ let fillerButton = FillerButton()
+
+ let containerView = NSView()
+ containerView.addSubview(scrollView)
+ containerView.addSubview(buttonBar)
+ containerView.addSubview(fillerButton)
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ buttonBar.translatesAutoresizingMaskIntoConstraints = false
+ fillerButton.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ containerView.topAnchor.constraint(equalTo: scrollView.topAnchor),
+ containerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: buttonBar.topAnchor, constant: 1),
+ containerView.leadingAnchor.constraint(equalTo: buttonBar.leadingAnchor),
+ containerView.bottomAnchor.constraint(equalTo: buttonBar.bottomAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: fillerButton.topAnchor, constant: 1),
+ containerView.bottomAnchor.constraint(equalTo: fillerButton.bottomAnchor),
+ buttonBar.trailingAnchor.constraint(equalTo: fillerButton.leadingAnchor, constant: 1),
+ fillerButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(equalToConstant: 180),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+ ])
+
+ addButton.menu?.items.forEach { $0.target = self }
+ actionButton.menu?.items.forEach { $0.target = self }
+
+ view = containerView
+ }
+
+ override func viewWillAppear() {
+ selectTunnelInOperation()
+ }
+
+ @discardableResult
+ func selectTunnelInOperation() -> Bool {
+ if let currentTunnel = tunnelsManager.tunnelInOperation(), let indexToSelect = tunnelsManager.index(of: currentTunnel) {
+ return selectTunnel(at: indexToSelect)
+ }
+ return false
+ }
+
+ @objc func handleAddEmptyTunnelAction() {
+ let tunnelEditVC = TunnelEditViewController(tunnelsManager: tunnelsManager, tunnel: nil)
+ tunnelEditVC.delegate = self
+ presentAsSheet(tunnelEditVC)
+ }
+
+ @objc func handleImportTunnelAction() {
+ ImportPanelPresenter.presentImportPanel(tunnelsManager: tunnelsManager, sourceVC: self)
+ }
+
+ @objc func handleRemoveTunnelAction() {
+ guard let window = view.window else { return }
+
+ let selectedTunnelIndices = tableView.selectedRowIndexes.sorted().filter { $0 >= 0 && $0 < tunnelsManager.numberOfTunnels() }
+ guard !selectedTunnelIndices.isEmpty else { return }
+ var nextSelection = selectedTunnelIndices.last! + 1
+ if nextSelection >= tunnelsManager.numberOfTunnels() {
+ nextSelection = max(selectedTunnelIndices.first! - 1, 0)
+ }
+
+ let alert = DeleteTunnelsConfirmationAlert()
+ if selectedTunnelIndices.count == 1 {
+ let firstSelectedTunnel = tunnelsManager.tunnel(at: selectedTunnelIndices.first!)
+ alert.messageText = tr(format: "macDeleteTunnelConfirmationAlertMessage (%@)", firstSelectedTunnel.name)
+ } else {
+ alert.messageText = tr(format: "macDeleteMultipleTunnelsConfirmationAlertMessage (%d)", selectedTunnelIndices.count)
+ }
+ alert.informativeText = tr("macDeleteTunnelConfirmationAlertInfo")
+ alert.onDeleteClicked = { [weak self] completion in
+ guard let self = self else { return }
+ self.selectTunnel(at: nextSelection)
+ let selectedTunnels = selectedTunnelIndices.map { self.tunnelsManager.tunnel(at: $0) }
+ self.isRemovingTunnelsFromWithinTheApp = true
+ self.tunnelsManager.removeMultiple(tunnels: selectedTunnels) { [weak self] error in
+ guard let self = self else { return }
+ self.isRemovingTunnelsFromWithinTheApp = false
+ defer { completion() }
+ if let error = error {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ }
+ }
+ }
+ alert.beginSheetModal(for: window)
+ }
+
+ @objc func handleViewLogAction() {
+ let logVC = LogViewController()
+ self.presentAsSheet(logVC)
+ }
+
+ @objc func handleExportTunnelsAction() {
+ PrivateDataConfirmation.confirmAccess(to: tr("macExportPrivateData")) { [weak self] in
+ guard let self = self else { return }
+ guard let window = self.view.window else { return }
+ let savePanel = NSSavePanel()
+ savePanel.allowedFileTypes = ["zip"]
+ savePanel.prompt = tr("macSheetButtonExportZip")
+ savePanel.nameFieldLabel = tr("macNameFieldExportZip")
+ savePanel.nameFieldStringValue = "wireguard-export.zip"
+ let tunnelsManager = self.tunnelsManager
+ savePanel.beginSheetModal(for: window) { [weak tunnelsManager] response in
+ guard let tunnelsManager = tunnelsManager else { return }
+ guard response == .OK else { return }
+ guard let destinationURL = savePanel.url else { return }
+ 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
+ }
+ }
+ }
+ }
+ }
+
+ @objc func listDoubleClicked(sender: AnyObject) {
+ let tunnelIndex = tableView.clickedRow
+ guard tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() else { return }
+ let tunnel = tunnelsManager.tunnel(at: tunnelIndex)
+ if tunnel.status == .inactive {
+ tunnelsManager.startActivation(of: tunnel)
+ } else if tunnel.status == .active {
+ tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+
+ @discardableResult
+ private func selectTunnel(at index: Int) -> Bool {
+ if index < tunnelsManager.numberOfTunnels() {
+ tableView.scrollRowToVisible(index)
+ tableView.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false)
+ return true
+ }
+ return false
+ }
+}
+
+extension TunnelsListTableViewController: TunnelEditViewControllerDelegate {
+ func tunnelSaved(tunnel: TunnelContainer) {
+ if let tunnelIndex = tunnelsManager.index(of: tunnel), tunnelIndex >= 0 {
+ self.selectTunnel(at: tunnelIndex)
+ }
+ }
+
+ func tunnelEditingCancelled() {
+ // Nothing to do
+ }
+}
+
+extension TunnelsListTableViewController {
+ func tunnelAdded(at index: Int) {
+ tableView.insertRows(at: IndexSet(integer: index), withAnimation: .slideLeft)
+ if tunnelsManager.numberOfTunnels() == 1 {
+ selectTunnel(at: 0)
+ }
+ if !NSApp.isActive {
+ // macOS's VPN prompt might have caused us to lose focus
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ }
+
+ func tunnelModified(at index: Int) {
+ tableView.reloadData(forRowIndexes: IndexSet(integer: index), columnIndexes: IndexSet(integer: 0))
+ }
+
+ func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
+ tableView.moveRow(at: oldIndex, to: newIndex)
+ }
+
+ func tunnelRemoved(at index: Int) {
+ let selectedIndices = tableView.selectedRowIndexes
+ let isSingleSelectedTunnelBeingRemoved = selectedIndices.contains(index) && selectedIndices.count == 1
+ tableView.removeRows(at: IndexSet(integer: index), withAnimation: .slideLeft)
+ if tunnelsManager.numberOfTunnels() == 0 {
+ delegate?.tunnelsListEmpty()
+ } else if !isRemovingTunnelsFromWithinTheApp && isSingleSelectedTunnelBeingRemoved {
+ let newSelection = min(index, tunnelsManager.numberOfTunnels() - 1)
+ tableView.selectRowIndexes(IndexSet(integer: newSelection), byExtendingSelection: false)
+ }
+ }
+}
+
+extension TunnelsListTableViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return tunnelsManager.numberOfTunnels()
+ }
+}
+
+extension TunnelsListTableViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ let cell: TunnelListRow = tableView.dequeueReusableCell()
+ cell.tunnel = tunnelsManager.tunnel(at: row)
+ return cell
+ }
+
+ func tableViewSelectionDidChange(_ notification: Notification) {
+ let selectedTunnelIndices = tableView.selectedRowIndexes.sorted()
+ if !selectedTunnelIndices.isEmpty {
+ delegate?.tunnelsSelected(tunnelIndices: tableView.selectedRowIndexes.sorted())
+ }
+ }
+}
+
+extension TunnelsListTableViewController {
+ override func keyDown(with event: NSEvent) {
+ if event.specialKey == .delete {
+ handleRemoveTunnelAction()
+ }
+ }
+}
+
+extension TunnelsListTableViewController: NSMenuItemValidation {
+ func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
+ if menuItem.action == #selector(TunnelsListTableViewController.handleRemoveTunnelAction) {
+ return !tableView.selectedRowIndexes.isEmpty
+ }
+ return true
+ }
+}
+
+class FillerButton: NSButton {
+ override var intrinsicContentSize: NSSize {
+ return NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
+ }
+
+ init() {
+ super.init(frame: CGRect.zero)
+ title = ""
+ bezelStyle = .smallSquare
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ // Eat mouseDown event, so that the button looks enabled but is unresponsive
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift
new file mode 100644
index 0000000..612e8c1
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class UnusableTunnelDetailViewController: NSViewController {
+
+ var onButtonClicked: (() -> Void)?
+
+ let messageLabel: NSTextField = {
+ let text = tr("macUnusableTunnelMessage")
+ let boldFont = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
+ let boldText = NSAttributedString(string: text, attributes: [.font: boldFont])
+ let label = NSTextField(labelWithAttributedString: boldText)
+ return label
+ }()
+
+ let infoLabel: NSTextField = {
+ let label = NSTextField(wrappingLabelWithString: tr("macUnusableTunnelInfo"))
+ return label
+ }()
+
+ let button: NSButton = {
+ let button = NSButton()
+ button.title = tr("macUnusableTunnelButtonTitleDeleteTunnel")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ init() {
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+
+ button.target = self
+ button.action = #selector(buttonClicked)
+
+ let margin: CGFloat = 20
+ let internalSpacing: CGFloat = 20
+ let buttonSpacing: CGFloat = 30
+ let stackView = NSStackView(views: [messageLabel, infoLabel, button])
+ stackView.orientation = .vertical
+ stackView.edgeInsets = NSEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)
+ stackView.spacing = internalSpacing
+ stackView.setCustomSpacing(buttonSpacing, after: infoLabel)
+
+ let view = NSView()
+ view.addSubview(stackView)
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ stackView.widthAnchor.constraint(equalToConstant: 360),
+ stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ view.widthAnchor.constraint(greaterThanOrEqualToConstant: 420),
+ view.heightAnchor.constraint(greaterThanOrEqualToConstant: 240)
+ ])
+
+ self.view = view
+ }
+
+ @objc func buttonClicked() {
+ onButtonClicked?()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/WireGuard.entitlements b/Sources/WireGuardApp/UI/macOS/WireGuard.entitlements
new file mode 100644
index 0000000..a39ba80
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/WireGuard.entitlements
@@ -0,0 +1,18 @@
+<?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.security.app-sandbox</key>
+ <true/>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>$(DEVELOPMENT_TEAM).group.$(APP_ID_MACOS)</string>
+ </array>
+ <key>com.apple.security.files.user-selected.read-write</key>
+ <true/>
+</dict>
+</plist>