]> git.ipfire.org Git - thirdparty/wireguard-apple.git/blob - Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift
0c44086ca4a6bb9b476338fa8104858744e0377a
[thirdparty/wireguard-apple.git] / Sources / WireGuardApp / UI / macOS / ViewController / TunnelDetailTableViewController.swift
1 // SPDX-License-Identifier: MIT
2 // Copyright © 2018-2021 WireGuard LLC. All Rights Reserved.
3
4 import Cocoa
5
6 class TunnelDetailTableViewController: NSViewController {
7
8 private enum TableViewModelRow {
9 case interfaceFieldRow(TunnelViewModel.InterfaceField)
10 case peerFieldRow(peer: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField)
11 case onDemandRow
12 case onDemandSSIDRow
13 case spacerRow
14
15 func localizedSectionKeyString() -> String {
16 switch self {
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 ""
22 }
23 }
24
25 func isTitleRow() -> Bool {
26 switch self {
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
32 }
33 }
34 }
35
36 static let interfaceFields: [TunnelViewModel.InterfaceField] = [
37 .name, .status, .publicKey, .addresses,
38 .listenPort, .mtu, .dns, .toggleStatus
39 ]
40
41 static let peerFields: [TunnelViewModel.PeerField] = [
42 .publicKey, .preSharedKey, .endpoint,
43 .allowedIPs, .persistentKeepAlive,
44 .rxBytes, .txBytes, .lastHandshakeTime
45 ]
46
47 static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
48 .onDemand, .ssid
49 ]
50
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
58 return tableView
59 }()
60
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")
67 return button
68 }()
69
70 let box: NSBox = {
71 let box = NSBox()
72 box.titlePosition = .noTitle
73 box.fillColor = .unemphasizedSelectedContentBackgroundColor
74 return box
75 }()
76
77 let tunnelsManager: TunnelsManager
78 let tunnel: TunnelContainer
79
80 var tunnelViewModel: TunnelViewModel {
81 didSet {
82 updateTableViewModelRowsBySection()
83 updateTableViewModelRows()
84 }
85 }
86
87 var onDemandViewModel: ActivateOnDemandViewModel
88
89 private var tableViewModelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
90 private var tableViewModelRows = [TableViewModelRow]()
91
92 private var statusObservationToken: AnyObject?
93 private var tunnelEditVC: TunnelEditViewController?
94 private var reloadRuntimeConfigurationTimer: Timer?
95
96 init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
97 self.tunnelsManager = tunnelsManager
98 self.tunnel = tunnel
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()
111 }
112 }
113 }
114
115 required init?(coder: NSCoder) {
116 fatalError("init(coder:) has not been implemented")
117 }
118
119 override func loadView() {
120 tableView.dataSource = self
121 tableView.delegate = self
122
123 editButton.target = self
124 editButton.action = #selector(handleEditTunnelAction)
125
126 let clipView = NSClipView()
127 clipView.documentView = tableView
128
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
134
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
144
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)
156 ])
157
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)
163 ])
164
165 NSLayoutConstraint.activate([
166 containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 320),
167 containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
168 ])
169
170 view = containerView
171 }
172
173 func updateTableViewModelRowsBySection() {
174 var modelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
175
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)))
181 }
182 interfaceSection.append((isVisible: true, modelRow: .spacerRow))
183 modelRowsBySection.append(interfaceSection)
184
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)))
189 }
190 peerSection.append((isVisible: true, modelRow: .spacerRow))
191 modelRowsBySection.append(peerSection)
192 }
193
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))
198 }
199 modelRowsBySection.append(onDemandSection)
200
201 tableViewModelRowsBySection = modelRowsBySection
202 }
203
204 func updateTableViewModelRows() {
205 tableViewModelRows = tableViewModelRowsBySection.flatMap { $0.filter { $0.isVisible }.map { $0.modelRow } }
206 }
207
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
215 }
216 }
217
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)
223 }
224 }
225
226 override func viewWillAppear() {
227 if tunnel.status == .active {
228 startUpdatingRuntimeConfiguration()
229 }
230 }
231
232 override func viewWillDisappear() {
233 super.viewWillDisappear()
234 if let tunnelEditVC = tunnelEditVC {
235 dismiss(tunnelEditVC)
236 }
237 stopUpdatingRuntimeConfiguration()
238 }
239
240 func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) {
241 // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering.
242
243 let tableView = self.tableView
244
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)
252 }
253 }
254 if !modifiedRowIndices.isEmpty {
255 tableView.reloadData(forRowIndexes: modifiedRowIndices, columnIndexes: IndexSet(integer: 0))
256 }
257 }
258
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
263 switch change {
264 case .added:
265 tableView.insertRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
266 modelRowsInSection[index].isVisible = true
267 case .removed:
268 tableView.removeRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
269 modelRowsInSection[index].isVisible = false
270 case .modified:
271 break
272 }
273 }
274 }
275
276 let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration)
277
278 if !changes.interfaceChanges.isEmpty {
279 handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields,
280 modelRowsInSection: self.tableViewModelRowsBySection[0],
281 rowOffset: 0, changes: changes.interfaceChanges)
282 }
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)
289 }
290
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 } }
293
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)
300 }
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)
306 }
307 }
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)
315 }
316 }
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))
324 }
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)
329 }
330 }
331 updateTableViewModelRows()
332 tableView.endUpdates()
333 }
334 }
335
336 private func reloadRuntimeConfiguration() {
337 tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in
338 guard let tunnelConfiguration = tunnelConfiguration else { return }
339 self?.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration)
340 }
341 }
342
343 func startUpdatingRuntimeConfiguration() {
344 reloadRuntimeConfiguration()
345 reloadRuntimeConfigurationTimer?.invalidate()
346 let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
347 self?.reloadRuntimeConfiguration()
348 }
349 reloadRuntimeConfigurationTimer = reloadTimer
350 RunLoop.main.add(reloadTimer, forMode: .common)
351 }
352
353 func stopUpdatingRuntimeConfiguration() {
354 reloadRuntimeConfiguration()
355 reloadRuntimeConfigurationTimer?.invalidate()
356 reloadRuntimeConfigurationTimer = nil
357 }
358
359 }
360
361 extension TunnelDetailTableViewController: NSTableViewDataSource {
362 func numberOfRows(in tableView: NSTableView) -> Int {
363 return tableViewModelRows.count
364 }
365 }
366
367 extension TunnelDetailTableViewController: NSTableViewDelegate {
368 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
369 let modelRow = tableViewModelRows[row]
370 switch modelRow {
371 case .interfaceFieldRow(let field):
372 if field == .status {
373 return statusCell()
374 } else if field == .toggleStatus {
375 return toggleStatusCell()
376 } else {
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()
382 return cell
383 }
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")
392 } else {
393 cell.value = peerData[field]
394 }
395 cell.isKeyInBold = modelRow.isTitleRow()
396 return cell
397 case .spacerRow:
398 return NSView()
399 case .onDemandRow:
400 let cell: KeyValueRow = tableView.dequeueReusableCell()
401 cell.key = modelRow.localizedSectionKeyString()
402 cell.value = onDemandViewModel.localizedInterfaceDescription
403 cell.isKeyInBold = true
404 return cell
405 case .onDemandSSIDRow:
406 let cell: KeyValueRow = tableView.dequeueReusableCell()
407 cell.key = tr("macFieldOnDemandSSIDs")
408 let value: String
409 if onDemandViewModel.ssidOption == .anySSID {
410 value = onDemandViewModel.ssidOption.localizedUIString
411 } else {
412 value = tr(format: "tunnelOnDemandSSIDOptionDescriptionMac (%1$@: %2$@)",
413 onDemandViewModel.ssidOption.localizedUIString,
414 onDemandViewModel.selectedSSIDs.joined(separator: ", "))
415 }
416 cell.value = value
417 cell.isKeyInBold = false
418 return cell
419 }
420 }
421
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)
431 }
432 return cell
433 }
434
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()
442 }
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)
447 }
448 return cell
449 }
450
451 private static func localizedStatusDescription(forStatus status: TunnelStatus) -> String {
452 switch status {
453 case .inactive:
454 return tr("tunnelStatusInactive")
455 case .activating:
456 return tr("tunnelStatusActivating")
457 case .active:
458 return tr("tunnelStatusActive")
459 case .deactivating:
460 return tr("tunnelStatusDeactivating")
461 case .reasserting:
462 return tr("tunnelStatusReasserting")
463 case .restarting:
464 return tr("tunnelStatusRestarting")
465 case .waiting:
466 return tr("tunnelStatusWaiting")
467 }
468 }
469
470 private static func image(forStatus status: TunnelStatus?) -> NSImage? {
471 guard let status = status else { return nil }
472 switch status {
473 case .active, .restarting, .reasserting:
474 return NSImage(named: NSImage.statusAvailableName)
475 case .activating, .waiting, .deactivating:
476 return NSImage(named: NSImage.statusPartiallyAvailableName)
477 case .inactive:
478 return NSImage(named: NSImage.statusNoneName)
479 }
480 }
481
482 private static func localizedToggleStatusActionText(forStatus status: TunnelStatus) -> String {
483 switch status {
484 case .waiting:
485 return tr("macToggleStatusButtonWaiting")
486 case .inactive:
487 return tr("macToggleStatusButtonActivate")
488 case .activating:
489 return tr("macToggleStatusButtonActivating")
490 case .active:
491 return tr("macToggleStatusButtonDeactivate")
492 case .deactivating:
493 return tr("macToggleStatusButtonDeactivating")
494 case .reasserting:
495 return tr("macToggleStatusButtonReasserting")
496 case .restarting:
497 return tr("macToggleStatusButtonRestarting")
498 }
499 }
500 }
501
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
510 }
511
512 func tunnelEditingCancelled() {
513 self.tunnelEditVC = nil
514 }
515 }