Move all source files to Sources/ and rename WireGuardKit targets

Signed-off-by: Andrej Mihajlov <and@mullvad.net>
This commit is contained in:
Andrej Mihajlov
2020-12-02 12:27:39 +01:00
parent 9c38a1b897
commit ec57408570
209 changed files with 54 additions and 58 deletions
@@ -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)
}
@@ -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")
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 942 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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?()
}
}
}
@@ -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)
}
}
}
+40
View File
@@ -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>
@@ -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
}
@@ -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>
@@ -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>
@@ -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;
}
@@ -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
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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
}
}
@@ -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), "")
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
]
}
@@ -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()
}
}
@@ -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
}
}
@@ -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 }
}
}
@@ -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")
}
}
@@ -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")
}
}
@@ -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() } ?? []
}
@@ -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
}
}
@@ -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;
}
@@ -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);
@@ -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?()
}
}
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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?()
}
}
@@ -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>