aboutsummaryrefslogtreecommitdiffstats
path: root/Sources/WireGuardApp/UI/macOS/StatusMenu.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/WireGuardApp/UI/macOS/StatusMenu.swift')
-rw-r--r--Sources/WireGuardApp/UI/macOS/StatusMenu.swift362
1 files changed, 362 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/macOS/StatusMenu.swift b/Sources/WireGuardApp/UI/macOS/StatusMenu.swift
new file mode 100644
index 0000000..45d2283
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/StatusMenu.swift
@@ -0,0 +1,362 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+protocol StatusMenuWindowDelegate: AnyObject {
+ func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?)
+}
+
+class StatusMenu: NSMenu {
+
+ let tunnelsManager: TunnelsManager
+
+ var statusMenuItem: NSMenuItem?
+ var networksMenuItem: NSMenuItem?
+ var deactivateMenuItem: NSMenuItem?
+
+ private let tunnelsBreakdownMenu = NSMenu()
+ private let tunnelsMenuItem = NSMenuItem(title: tr("macTunnelsMenuTitle"), action: nil, keyEquivalent: "")
+ private let tunnelsMenuSeparatorItem = NSMenuItem.separator()
+
+ private var firstTunnelMenuItemIndex = 0
+ private var numberOfTunnelMenuItems = 0
+ private var tunnelsPresentationStyle = StatusMenuTunnelsPresentationStyle.inline
+
+ 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())
+
+ tunnelsMenuItem.submenu = tunnelsBreakdownMenu
+ addItem(tunnelsMenuItem)
+
+ firstTunnelMenuItemIndex = numberOfItems
+ populateInitialTunnelMenuItems()
+
+ addItem(tunnelsMenuSeparatorItem)
+
+ 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 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 }
+ let tunnel = tunnelMenuItem.tunnel
+ if tunnel.hasOnDemandRules {
+ let turnOn = !tunnel.isActivateOnDemandEnabled
+ tunnelsManager.setOnDemandEnabled(turnOn, on: tunnel) { error in
+ if error == nil && !turnOn {
+ self.tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+ } else {
+ if tunnel.status == .inactive {
+ tunnelsManager.startActivation(of: tunnel)
+ } else if tunnel.status == .active {
+ tunnelsManager.startDeactivation(of: 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 nextNumberOfTunnels = numberOfTunnelMenuItems + 1
+
+ guard !reparentTunnelMenuItems(nextNumberOfTunnels: nextNumberOfTunnels) else {
+ return
+ }
+
+ let menuItem = makeTunnelItem(tunnel: tunnel)
+ switch tunnelsPresentationStyle {
+ case .submenu:
+ tunnelsBreakdownMenu.insertItem(menuItem, at: tunnelIndex)
+ case .inline:
+ insertItem(menuItem, at: firstTunnelMenuItemIndex + tunnelIndex)
+ }
+
+ numberOfTunnelMenuItems = nextNumberOfTunnels
+ updateTunnelsMenuItemVisibility()
+ }
+
+ func removeTunnelMenuItem(at tunnelIndex: Int) {
+ let nextNumberOfTunnels = numberOfTunnelMenuItems - 1
+
+ guard !reparentTunnelMenuItems(nextNumberOfTunnels: nextNumberOfTunnels) else {
+ return
+ }
+
+ switch tunnelsPresentationStyle {
+ case .submenu:
+ tunnelsBreakdownMenu.removeItem(at: tunnelIndex)
+ case .inline:
+ removeItem(at: firstTunnelMenuItemIndex + tunnelIndex)
+ }
+
+ numberOfTunnelMenuItems = nextNumberOfTunnels
+ updateTunnelsMenuItemVisibility()
+ }
+
+ func moveTunnelMenuItem(from oldTunnelIndex: Int, to newTunnelIndex: Int) {
+ let tunnel = tunnelsManager.tunnel(at: newTunnelIndex)
+ let menuItem = makeTunnelItem(tunnel: tunnel)
+
+ switch tunnelsPresentationStyle {
+ case .submenu:
+ tunnelsBreakdownMenu.removeItem(at: oldTunnelIndex)
+ tunnelsBreakdownMenu.insertItem(menuItem, at: newTunnelIndex)
+ case .inline:
+ removeItem(at: firstTunnelMenuItemIndex + oldTunnelIndex)
+ insertItem(menuItem, at: firstTunnelMenuItemIndex + newTunnelIndex)
+ }
+ }
+
+ private func makeTunnelItem(tunnel: TunnelContainer) -> TunnelMenuItem {
+ let menuItem = TunnelMenuItem(tunnel: tunnel, action: #selector(tunnelClicked(sender:)))
+ menuItem.target = self
+ menuItem.isHidden = !tunnel.isTunnelAvailableToUser
+ return menuItem
+ }
+
+ private func populateInitialTunnelMenuItems() {
+ let numberOfTunnels = tunnelsManager.numberOfTunnels()
+ let initialStyle = tunnelsPresentationStyle.preferredPresentationStyle(numberOfTunnels: numberOfTunnels)
+
+ tunnelsPresentationStyle = initialStyle
+ switch initialStyle {
+ case .inline:
+ numberOfTunnelMenuItems = addTunnelMenuItems(into: self, at: firstTunnelMenuItemIndex)
+ case .submenu:
+ numberOfTunnelMenuItems = addTunnelMenuItems(into: tunnelsBreakdownMenu, at: 0)
+ }
+
+ updateTunnelsMenuItemVisibility()
+ }
+
+ private func reparentTunnelMenuItems(nextNumberOfTunnels: Int) -> Bool {
+ let nextStyle = tunnelsPresentationStyle.preferredPresentationStyle(numberOfTunnels: nextNumberOfTunnels)
+
+ switch (tunnelsPresentationStyle, nextStyle) {
+ case (.inline, .submenu):
+ tunnelsPresentationStyle = nextStyle
+ for index in (0..<numberOfTunnelMenuItems).reversed() {
+ removeItem(at: firstTunnelMenuItemIndex + index)
+ }
+ numberOfTunnelMenuItems = addTunnelMenuItems(into: tunnelsBreakdownMenu, at: 0)
+ updateTunnelsMenuItemVisibility()
+ return true
+
+ case (.submenu, .inline):
+ tunnelsPresentationStyle = nextStyle
+ tunnelsBreakdownMenu.removeAllItems()
+ numberOfTunnelMenuItems = addTunnelMenuItems(into: self, at: firstTunnelMenuItemIndex)
+ updateTunnelsMenuItemVisibility()
+ return true
+
+ case (.submenu, .submenu), (.inline, .inline):
+ return false
+ }
+ }
+
+ private func addTunnelMenuItems(into menu: NSMenu, at startIndex: Int) -> Int {
+ let numberOfTunnels = tunnelsManager.numberOfTunnels()
+ for tunnelIndex in 0..<numberOfTunnels {
+ let tunnel = tunnelsManager.tunnel(at: tunnelIndex)
+ let menuItem = makeTunnelItem(tunnel: tunnel)
+ menu.insertItem(menuItem, at: startIndex + tunnelIndex)
+ }
+ return numberOfTunnels
+ }
+
+ private func updateTunnelsMenuItemVisibility() {
+ switch tunnelsPresentationStyle {
+ case .inline:
+ tunnelsMenuItem.isHidden = true
+ case .submenu:
+ tunnelsMenuItem.isHidden = false
+ }
+ tunnelsMenuSeparatorItem.isHidden = numberOfTunnelMenuItems == 0
+ }
+}
+
+class TunnelMenuItem: NSMenuItem {
+
+ var tunnel: TunnelContainer
+
+ private var statusObservationToken: AnyObject?
+ private var nameObservationToken: AnyObject?
+ private var isOnDemandEnabledObservationToken: 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()
+ }
+ let isOnDemandEnabledObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak self] _, _ in
+ self?.updateTitle()
+ self?.updateStatus()
+ }
+ self.statusObservationToken = statusObservationToken
+ self.isOnDemandEnabledObservationToken = isOnDemandEnabledObservationToken
+ self.nameObservationToken = nameObservationToken
+ }
+
+ required init(coder decoder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func updateTitle() {
+ if tunnel.isActivateOnDemandEnabled {
+ title = tunnel.name + " (On-Demand)"
+ } else {
+ title = tunnel.name
+ }
+ }
+
+ func updateStatus() {
+ if tunnel.isActivateOnDemandEnabled {
+ state = (tunnel.status == .inactive || tunnel.status == .deactivating) ? .mixed : .on
+ } else {
+ state = (tunnel.status == .inactive || tunnel.status == .deactivating) ? .off : .on
+ }
+ }
+}
+
+private enum StatusMenuTunnelsPresentationStyle {
+ case inline
+ case submenu
+
+ func preferredPresentationStyle(numberOfTunnels: Int) -> StatusMenuTunnelsPresentationStyle {
+ let maxInlineTunnels = 10
+
+ if case .inline = self, numberOfTunnels > maxInlineTunnels {
+ return .submenu
+ } else if case .submenu = self, numberOfTunnels <= maxInlineTunnels {
+ return .inline
+ } else {
+ return self
+ }
+ }
+}