1 // SPDX-License-Identifier: MIT
2 // Copyright © 2018-2021 WireGuard LLC. All Rights Reserved.
6 class TunnelDetailTableViewController: NSViewController {
8 private enum TableViewModelRow {
9 case interfaceFieldRow(TunnelViewModel.InterfaceField)
10 case peerFieldRow(peer: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField)
15 func localizedSectionKeyString() -> String {
17 case .interfaceFieldRow: return tr("tunnelSectionTitleInterface")
18 case .peerFieldRow: return tr("tunnelSectionTitlePeer")
19 case .onDemandRow: return tr("macFieldOnDemand")
20 case .onDemandSSIDRow: return ""
21 case .spacerRow: return ""
25 func isTitleRow() -> Bool {
27 case .interfaceFieldRow(let field): return field == .name
28 case .peerFieldRow(_, let field): return field == .publicKey
29 case .onDemandRow: return true
30 case .onDemandSSIDRow: return false
31 case .spacerRow: return false
36 static let interfaceFields: [TunnelViewModel.InterfaceField] = [
37 .name, .status, .publicKey, .addresses,
38 .listenPort, .mtu, .dns, .toggleStatus
41 static let peerFields: [TunnelViewModel.PeerField] = [
42 .publicKey, .preSharedKey, .endpoint,
43 .allowedIPs, .persistentKeepAlive,
44 .rxBytes, .txBytes, .lastHandshakeTime
47 static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
51 let tableView: NSTableView = {
52 let tableView = NSTableView()
53 tableView.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TunnelDetail")))
54 tableView.headerView = nil
55 tableView.rowSizeStyle = .medium
56 tableView.backgroundColor = .clear
57 tableView.selectionHighlightStyle = .none
61 let editButton: NSButton = {
62 let button = NSButton()
63 button.title = tr("macButtonEdit")
64 button.setButtonType(.momentaryPushIn)
65 button.bezelStyle = .rounded
66 button.toolTip = tr("macToolTipEditTunnel")
72 box.titlePosition = .noTitle
73 box.fillColor = .unemphasizedSelectedContentBackgroundColor
77 let tunnelsManager: TunnelsManager
78 let tunnel: TunnelContainer
80 var tunnelViewModel: TunnelViewModel {
82 updateTableViewModelRowsBySection()
83 updateTableViewModelRows()
87 var onDemandViewModel: ActivateOnDemandViewModel
89 private var tableViewModelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
90 private var tableViewModelRows = [TableViewModelRow]()
92 private var statusObservationToken: AnyObject?
93 private var tunnelEditVC: TunnelEditViewController?
94 private var reloadRuntimeConfigurationTimer: Timer?
96 init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
97 self.tunnelsManager = tunnelsManager
99 tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
100 onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
101 super.init(nibName: nil, bundle: nil)
102 updateTableViewModelRowsBySection()
103 updateTableViewModelRows()
104 statusObservationToken = tunnel.observe(\TunnelContainer.status) { [weak self] _, _ in
105 guard let self = self else { return }
106 if tunnel.status == .active {
107 self.startUpdatingRuntimeConfiguration()
108 } else if tunnel.status == .inactive {
109 self.reloadRuntimeConfiguration()
110 self.stopUpdatingRuntimeConfiguration()
115 required init?(coder: NSCoder) {
116 fatalError("init(coder:) has not been implemented")
119 override func loadView() {
120 tableView.dataSource = self
121 tableView.delegate = self
123 editButton.target = self
124 editButton.action = #selector(handleEditTunnelAction)
126 let clipView = NSClipView()
127 clipView.documentView = tableView
129 let scrollView = NSScrollView()
130 scrollView.contentView = clipView // Set contentView before setting drawsBackground
131 scrollView.drawsBackground = false
132 scrollView.hasVerticalScroller = true
133 scrollView.autohidesScrollers = true
135 let containerView = NSView()
136 let bottomControlsContainer = NSLayoutGuide()
137 containerView.addLayoutGuide(bottomControlsContainer)
138 containerView.addSubview(box)
139 containerView.addSubview(scrollView)
140 containerView.addSubview(editButton)
141 box.translatesAutoresizingMaskIntoConstraints = false
142 scrollView.translatesAutoresizingMaskIntoConstraints = false
143 editButton.translatesAutoresizingMaskIntoConstraints = false
145 NSLayoutConstraint.activate([
146 containerView.topAnchor.constraint(equalTo: scrollView.topAnchor),
147 containerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
148 containerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
149 containerView.leadingAnchor.constraint(equalTo: bottomControlsContainer.leadingAnchor),
150 containerView.trailingAnchor.constraint(equalTo: bottomControlsContainer.trailingAnchor),
151 bottomControlsContainer.heightAnchor.constraint(equalToConstant: 32),
152 scrollView.bottomAnchor.constraint(equalTo: bottomControlsContainer.topAnchor),
153 bottomControlsContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
154 editButton.trailingAnchor.constraint(equalTo: bottomControlsContainer.trailingAnchor),
155 bottomControlsContainer.bottomAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 0)
158 NSLayoutConstraint.activate([
159 scrollView.topAnchor.constraint(equalTo: box.topAnchor),
160 scrollView.bottomAnchor.constraint(equalTo: box.bottomAnchor),
161 scrollView.leadingAnchor.constraint(equalTo: box.leadingAnchor),
162 scrollView.trailingAnchor.constraint(equalTo: box.trailingAnchor)
165 NSLayoutConstraint.activate([
166 containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 320),
167 containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
173 func updateTableViewModelRowsBySection() {
174 var modelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
176 var interfaceSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
177 for field in TunnelDetailTableViewController.interfaceFields {
178 let isStatus = field == .status || field == .toggleStatus
179 let isEmpty = tunnelViewModel.interfaceData[field].isEmpty
180 interfaceSection.append((isVisible: isStatus || !isEmpty, modelRow: .interfaceFieldRow(field)))
182 interfaceSection.append((isVisible: true, modelRow: .spacerRow))
183 modelRowsBySection.append(interfaceSection)
185 for peerData in tunnelViewModel.peersData {
186 var peerSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
187 for field in TunnelDetailTableViewController.peerFields {
188 peerSection.append((isVisible: !peerData[field].isEmpty, modelRow: .peerFieldRow(peer: peerData, field: field)))
190 peerSection.append((isVisible: true, modelRow: .spacerRow))
191 modelRowsBySection.append(peerSection)
194 var onDemandSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
195 onDemandSection.append((isVisible: true, modelRow: .onDemandRow))
196 if onDemandViewModel.isWiFiInterfaceEnabled {
197 onDemandSection.append((isVisible: true, modelRow: .onDemandSSIDRow))
199 modelRowsBySection.append(onDemandSection)
201 tableViewModelRowsBySection = modelRowsBySection
204 func updateTableViewModelRows() {
205 tableViewModelRows = tableViewModelRowsBySection.flatMap { $0.filter { $0.isVisible }.map { $0.modelRow } }
208 @objc func handleEditTunnelAction() {
209 PrivateDataConfirmation.confirmAccess(to: tr("macViewPrivateData")) { [weak self] in
210 guard let self = self else { return }
211 let tunnelEditVC = TunnelEditViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel)
212 tunnelEditVC.delegate = self
213 self.presentAsSheet(tunnelEditVC)
214 self.tunnelEditVC = tunnelEditVC
218 @objc func handleToggleActiveStatusAction() {
219 if tunnel.status == .inactive {
220 tunnelsManager.startActivation(of: tunnel)
221 } else if tunnel.status == .active {
222 tunnelsManager.startDeactivation(of: tunnel)
226 override func viewWillAppear() {
227 if tunnel.status == .active {
228 startUpdatingRuntimeConfiguration()
232 override func viewWillDisappear() {
233 super.viewWillDisappear()
234 if let tunnelEditVC = tunnelEditVC {
235 dismiss(tunnelEditVC)
237 stopUpdatingRuntimeConfiguration()
240 func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) {
241 // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering.
243 let tableView = self.tableView
245 func handleSectionFieldsModified<T>(fields: [T], modelRowsInSection: [(isVisible: Bool, modelRow: TableViewModelRow)], rowOffset: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
246 var modifiedRowIndices = IndexSet()
247 for (index, field) in fields.enumerated() {
248 guard let change = changes[field] else { continue }
249 if case .modified = change {
250 let row = modelRowsInSection[0 ..< index].filter { $0.isVisible }.count
251 modifiedRowIndices.insert(rowOffset + row)
254 if !modifiedRowIndices.isEmpty {
255 tableView.reloadData(forRowIndexes: modifiedRowIndices, columnIndexes: IndexSet(integer: 0))
259 func handleSectionFieldsAddedOrRemoved<T>(fields: [T], modelRowsInSection: inout [(isVisible: Bool, modelRow: TableViewModelRow)], rowOffset: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
260 for (index, field) in fields.enumerated() {
261 guard let change = changes[field] else { continue }
262 let row = modelRowsInSection[0 ..< index].filter { $0.isVisible }.count
265 tableView.insertRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
266 modelRowsInSection[index].isVisible = true
268 tableView.removeRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
269 modelRowsInSection[index].isVisible = false
276 let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration)
278 if !changes.interfaceChanges.isEmpty {
279 handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields,
280 modelRowsInSection: self.tableViewModelRowsBySection[0],
281 rowOffset: 0, changes: changes.interfaceChanges)
283 for (peerIndex, peerChanges) in changes.peerChanges {
284 let sectionIndex = 1 + peerIndex
285 let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
286 handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields,
287 modelRowsInSection: self.tableViewModelRowsBySection[sectionIndex],
288 rowOffset: rowOffset, changes: peerChanges)
291 let isAnyInterfaceFieldAddedOrRemoved = changes.interfaceChanges.contains { $0.value == .added || $0.value == .removed }
292 let isAnyPeerFieldAddedOrRemoved = changes.peerChanges.contains { $0.changes.contains { $0.value == .added || $0.value == .removed } }
294 if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !changes.peersRemovedIndices.isEmpty || !changes.peersInsertedIndices.isEmpty {
295 tableView.beginUpdates()
296 if isAnyInterfaceFieldAddedOrRemoved {
297 handleSectionFieldsAddedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields,
298 modelRowsInSection: &self.tableViewModelRowsBySection[0],
299 rowOffset: 0, changes: changes.interfaceChanges)
301 if isAnyPeerFieldAddedOrRemoved {
302 for (peerIndex, peerChanges) in changes.peerChanges {
303 let sectionIndex = 1 + peerIndex
304 let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
305 handleSectionFieldsAddedOrRemoved(fields: TunnelDetailTableViewController.peerFields, modelRowsInSection: &self.tableViewModelRowsBySection[sectionIndex], rowOffset: rowOffset, changes: peerChanges)
308 if !changes.peersRemovedIndices.isEmpty {
309 for peerIndex in changes.peersRemovedIndices {
310 let sectionIndex = 1 + peerIndex
311 let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
312 let count = self.tableViewModelRowsBySection[sectionIndex].filter { $0.isVisible }.count
313 self.tableView.removeRows(at: IndexSet(integersIn: rowOffset ..< rowOffset + count), withAnimation: .effectFade)
314 self.tableViewModelRowsBySection.remove(at: sectionIndex)
317 if !changes.peersInsertedIndices.isEmpty {
318 for peerIndex in changes.peersInsertedIndices {
319 let peerData = self.tunnelViewModel.peersData[peerIndex]
320 let sectionIndex = 1 + peerIndex
321 let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
322 var modelRowsInSection: [(isVisible: Bool, modelRow: TableViewModelRow)] = TunnelDetailTableViewController.peerFields.map {
323 (isVisible: !peerData[$0].isEmpty, modelRow: .peerFieldRow(peer: peerData, field: $0))
325 modelRowsInSection.append((isVisible: true, modelRow: .spacerRow))
326 let count = modelRowsInSection.filter { $0.isVisible }.count
327 self.tableView.insertRows(at: IndexSet(integersIn: rowOffset ..< rowOffset + count), withAnimation: .effectFade)
328 self.tableViewModelRowsBySection.insert(modelRowsInSection, at: sectionIndex)
331 updateTableViewModelRows()
332 tableView.endUpdates()
336 private func reloadRuntimeConfiguration() {
337 tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in
338 guard let tunnelConfiguration = tunnelConfiguration else { return }
339 self?.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration)
343 func startUpdatingRuntimeConfiguration() {
344 reloadRuntimeConfiguration()
345 reloadRuntimeConfigurationTimer?.invalidate()
346 let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
347 self?.reloadRuntimeConfiguration()
349 reloadRuntimeConfigurationTimer = reloadTimer
350 RunLoop.main.add(reloadTimer, forMode: .common)
353 func stopUpdatingRuntimeConfiguration() {
354 reloadRuntimeConfiguration()
355 reloadRuntimeConfigurationTimer?.invalidate()
356 reloadRuntimeConfigurationTimer = nil
361 extension TunnelDetailTableViewController: NSTableViewDataSource {
362 func numberOfRows(in tableView: NSTableView) -> Int {
363 return tableViewModelRows.count
367 extension TunnelDetailTableViewController: NSTableViewDelegate {
368 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
369 let modelRow = tableViewModelRows[row]
371 case .interfaceFieldRow(let field):
372 if field == .status {
374 } else if field == .toggleStatus {
375 return toggleStatusCell()
377 let cell: KeyValueRow = tableView.dequeueReusableCell()
378 let localizedKeyString = modelRow.isTitleRow() ? modelRow.localizedSectionKeyString() : field.localizedUIString
379 cell.key = tr(format: "macFieldKey (%@)", localizedKeyString)
380 cell.value = tunnelViewModel.interfaceData[field]
381 cell.isKeyInBold = modelRow.isTitleRow()
384 case .peerFieldRow(let peerData, let field):
385 let cell: KeyValueRow = tableView.dequeueReusableCell()
386 let localizedKeyString = modelRow.isTitleRow() ? modelRow.localizedSectionKeyString() : field.localizedUIString
387 cell.key = tr(format: "macFieldKey (%@)", localizedKeyString)
388 if field == .persistentKeepAlive {
389 cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field])
390 } else if field == .preSharedKey {
391 cell.value = tr("tunnelPeerPresharedKeyEnabled")
393 cell.value = peerData[field]
395 cell.isKeyInBold = modelRow.isTitleRow()
400 let cell: KeyValueRow = tableView.dequeueReusableCell()
401 cell.key = modelRow.localizedSectionKeyString()
402 cell.value = onDemandViewModel.localizedInterfaceDescription
403 cell.isKeyInBold = true
405 case .onDemandSSIDRow:
406 let cell: KeyValueRow = tableView.dequeueReusableCell()
407 cell.key = tr("macFieldOnDemandSSIDs")
409 if onDemandViewModel.ssidOption == .anySSID {
410 value = onDemandViewModel.ssidOption.localizedUIString
412 value = tr(format: "tunnelOnDemandSSIDOptionDescriptionMac (%1$@: %2$@)",
413 onDemandViewModel.ssidOption.localizedUIString,
414 onDemandViewModel.selectedSSIDs.joined(separator: ", "))
417 cell.isKeyInBold = false
422 func statusCell() -> NSView {
423 let cell: KeyValueImageRow = tableView.dequeueReusableCell()
424 cell.key = tr(format: "macFieldKey (%@)", tr("tunnelInterfaceStatus"))
425 cell.value = TunnelDetailTableViewController.localizedStatusDescription(forStatus: tunnel.status)
426 cell.valueImage = TunnelDetailTableViewController.image(forStatus: tunnel.status)
427 cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
428 guard let cell = cell else { return }
429 cell.value = TunnelDetailTableViewController.localizedStatusDescription(forStatus: tunnel.status)
430 cell.valueImage = TunnelDetailTableViewController.image(forStatus: tunnel.status)
435 func toggleStatusCell() -> NSView {
436 let cell: ButtonRow = tableView.dequeueReusableCell()
437 cell.buttonTitle = TunnelDetailTableViewController.localizedToggleStatusActionText(forStatus: tunnel.status)
438 cell.isButtonEnabled = (tunnel.status == .active || tunnel.status == .inactive)
439 cell.buttonToolTip = tr("macToolTipToggleStatus")
440 cell.onButtonClicked = { [weak self] in
441 self?.handleToggleActiveStatusAction()
443 cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
444 guard let cell = cell else { return }
445 cell.buttonTitle = TunnelDetailTableViewController.localizedToggleStatusActionText(forStatus: tunnel.status)
446 cell.isButtonEnabled = (tunnel.status == .active || tunnel.status == .inactive)
451 private static func localizedStatusDescription(forStatus status: TunnelStatus) -> String {
454 return tr("tunnelStatusInactive")
456 return tr("tunnelStatusActivating")
458 return tr("tunnelStatusActive")
460 return tr("tunnelStatusDeactivating")
462 return tr("tunnelStatusReasserting")
464 return tr("tunnelStatusRestarting")
466 return tr("tunnelStatusWaiting")
470 private static func image(forStatus status: TunnelStatus?) -> NSImage? {
471 guard let status = status else { return nil }
473 case .active, .restarting, .reasserting:
474 return NSImage(named: NSImage.statusAvailableName)
475 case .activating, .waiting, .deactivating:
476 return NSImage(named: NSImage.statusPartiallyAvailableName)
478 return NSImage(named: NSImage.statusNoneName)
482 private static func localizedToggleStatusActionText(forStatus status: TunnelStatus) -> String {
485 return tr("macToggleStatusButtonWaiting")
487 return tr("macToggleStatusButtonActivate")
489 return tr("macToggleStatusButtonActivating")
491 return tr("macToggleStatusButtonDeactivate")
493 return tr("macToggleStatusButtonDeactivating")
495 return tr("macToggleStatusButtonReasserting")
497 return tr("macToggleStatusButtonRestarting")
502 extension TunnelDetailTableViewController: TunnelEditViewControllerDelegate {
503 func tunnelSaved(tunnel: TunnelContainer) {
504 tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
505 onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
506 updateTableViewModelRowsBySection()
507 updateTableViewModelRows()
508 tableView.reloadData()
509 self.tunnelEditVC = nil
512 func tunnelEditingCancelled() {
513 self.tunnelEditVC = nil