Move all source files to Sources/ and rename WireGuardKit targets
Signed-off-by: Andrej Mihajlov <and@mullvad.net>
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 978 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 881 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 953 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 942 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||