diff options
author | Andrej Mihajlov <and@mullvad.net> | 2020-12-02 12:27:39 +0100 |
---|---|---|
committer | Andrej Mihajlov <and@mullvad.net> | 2020-12-03 13:32:24 +0100 |
commit | ec574085703ea1c8b2d4538596961beb910c4382 (patch) | |
tree | 73cf8bbdb74fe5575606664bccd0232ffa911803 /Sources/WireGuardApp/UI/macOS | |
parent | WireGuardKit: Assert that resolutionResults must not contain failures (diff) | |
download | wireguard-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')
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 Binary files differnew file mode 100644 index 0000000..83c4701 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png Binary files differnew file mode 100644 index 0000000..16f9056 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png Binary files differnew file mode 100644 index 0000000..e05c706 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png 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 Binary files differnew file mode 100644 index 0000000..ac09bdd --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png Binary files differnew file mode 100644 index 0000000..ac09bdd --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png 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 Binary files differnew file mode 100644 index 0000000..edf0ca5 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png Binary files differnew file mode 100644 index 0000000..edf0ca5 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png 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 Binary files differnew file mode 100644 index 0000000..54b28ff --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png Binary files differnew file mode 100644 index 0000000..54b28ff --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png Binary files differnew file mode 100644 index 0000000..98441a8 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png 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 Binary files differnew file mode 100644 index 0000000..c0a43e7 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png 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 Binary files differnew file mode 100644 index 0000000..2057c31 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png 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 Binary files differnew file mode 100644 index 0000000..60cc363 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png 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 Binary files differnew file mode 100644 index 0000000..fb9d8f7 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png 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 Binary files differnew file mode 100644 index 0000000..2f4e613 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png 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 Binary files differnew file mode 100644 index 0000000..cc5ead9 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png 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 Binary files differnew file mode 100644 index 0000000..bbbe0c3 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png 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 Binary files differnew file mode 100644 index 0000000..01b5eb3 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png 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 Binary files differnew file mode 100644 index 0000000..76afa15 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png 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 Binary files differnew file mode 100644 index 0000000..a16143f --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png 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 Binary files differnew file mode 100644 index 0000000..ce00482 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png 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 Binary files differnew file mode 100644 index 0000000..82640f7 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png 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 Binary files differnew file mode 100644 index 0000000..80e221b --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png 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 Binary files differnew file mode 100644 index 0000000..663f92b --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png 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 Binary files differnew file mode 100644 index 0000000..10e43d9 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png 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> |