diff --git a/WireGuard/WireGuard.xcodeproj/project.pbxproj b/WireGuard/WireGuard.xcodeproj/project.pbxproj index ed00af9..8ecccda 100644 --- a/WireGuard/WireGuard.xcodeproj/project.pbxproj +++ b/WireGuard/WireGuard.xcodeproj/project.pbxproj @@ -158,6 +158,8 @@ 6FCD99B121E0EDA900BA4C82 /* TunnelEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FCD99B021E0EDA900BA4C82 /* TunnelEditViewController.swift */; }; 6FDB3C3B21DCF47400A0C0BF /* TunnelDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDB3C3A21DCF47400A0C0BF /* TunnelDetailTableViewController.swift */; }; 6FDB3C3C21DCF6BB00A0C0BF /* TunnelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F628C3C217F09E9003482A3 /* TunnelViewModel.swift */; }; + 6FDB6D13224A15BF00EE4BC3 /* LogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDB6D12224A15BE00EE4BC3 /* LogViewController.swift */; }; + 6FDB6D15224CB2CE00EE4BC3 /* LogViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDB6D14224CB2CE00EE4BC3 /* LogViewCell.swift */; }; 6FDEF7E421846C1A00D8FBF6 /* libwg-go.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6FDEF7E321846C1A00D8FBF6 /* libwg-go.a */; }; 6FDEF7E62185EFB200D8FBF6 /* QRScanViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDEF7E52185EFAF00D8FBF6 /* QRScanViewController.swift */; }; 6FDEF7FB21863B6100D8FBF6 /* unzip.c in Sources */ = {isa = PBXBuildFile; fileRef = 6FDEF7F621863B6100D8FBF6 /* unzip.c */; }; @@ -360,6 +362,8 @@ 6FCD99AE21E0EA1700BA4C82 /* ImportPanelPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportPanelPresenter.swift; sourceTree = ""; }; 6FCD99B021E0EDA900BA4C82 /* TunnelEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelEditViewController.swift; sourceTree = ""; }; 6FDB3C3A21DCF47400A0C0BF /* TunnelDetailTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelDetailTableViewController.swift; sourceTree = ""; }; + 6FDB6D12224A15BE00EE4BC3 /* LogViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogViewController.swift; sourceTree = ""; }; + 6FDB6D14224CB2CE00EE4BC3 /* LogViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewCell.swift; sourceTree = ""; }; 6FDEF7E321846C1A00D8FBF6 /* libwg-go.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = "libwg-go.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 6FDEF7E52185EFAF00D8FBF6 /* QRScanViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScanViewController.swift; sourceTree = ""; }; 6FDEF7F621863B6100D8FBF6 /* unzip.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = unzip.c; sourceTree = ""; }; @@ -473,6 +477,7 @@ 6FE3661C21F64F6B00F78C7D /* ConfTextColorTheme.swift */, 6F5EA59A223E58A8002B380A /* ButtonRow.swift */, 6FB17945222FD5960018AE71 /* OnDemandWiFiControls.swift */, + 6FDB6D14224CB2CE00EE4BC3 /* LogViewCell.swift */, ); path = View; sourceTree = ""; @@ -637,6 +642,7 @@ 6FDB3C3A21DCF47400A0C0BF /* TunnelDetailTableViewController.swift */, 6FCD99A821E0E0C700BA4C82 /* ButtonedDetailViewController.swift */, 6FCD99B021E0EDA900BA4C82 /* TunnelEditViewController.swift */, + 6FDB6D12224A15BE00EE4BC3 /* LogViewController.swift */, ); path = ViewController; sourceTree = ""; @@ -1238,6 +1244,7 @@ 6FB1BDD321D50F5300A991BF /* ZipArchive.swift in Sources */, 6FB1BDD421D50F5300A991BF /* ioapi.c in Sources */, 6FDB3C3C21DCF6BB00A0C0BF /* TunnelViewModel.swift in Sources */, + 6FDB6D13224A15BF00EE4BC3 /* LogViewController.swift in Sources */, 6B5C5E29220A48D30024272E /* Keychain.swift in Sources */, 6FCD99AF21E0EA1700BA4C82 /* ImportPanelPresenter.swift in Sources */, 6FB1BDD521D50F5300A991BF /* unzip.c in Sources */, @@ -1266,6 +1273,7 @@ 6F89E17A21EDEB0E00C97BB9 /* StatusItemController.swift in Sources */, 6F5EA59B223E58A8002B380A /* ButtonRow.swift in Sources */, 6F4DD16B21DA558800690EAE /* TunnelListRow.swift in Sources */, + 6FDB6D15224CB2CE00EE4BC3 /* LogViewCell.swift in Sources */, 6FE3661D21F64F6B00F78C7D /* ConfTextColorTheme.swift in Sources */, 5F52D0BF21E3788900283CEA /* NSColor+Hex.swift in Sources */, 6FB1BDBE21D50F0200A991BF /* Logger.swift in Sources */, diff --git a/WireGuard/WireGuard/Base.lproj/Localizable.strings b/WireGuard/WireGuard/Base.lproj/Localizable.strings index 5289c72..0872596 100644 --- a/WireGuard/WireGuard/Base.lproj/Localizable.strings +++ b/WireGuard/WireGuard/Base.lproj/Localizable.strings @@ -296,7 +296,7 @@ "macMenuManageTunnels" = "Manage tunnels"; "macMenuImportTunnels" = "Import tunnel(s) from file…"; "macMenuAddEmptyTunnel" = "Add empty tunnel…"; -"macMenuExportLog" = "Export log to file…"; +"macMenuViewLog" = "View log"; "macMenuExportTunnels" = "Export tunnels to zip…"; "macMenuAbout" = "About WireGuard"; "macMenuQuit" = "Quit"; @@ -387,3 +387,10 @@ "macToolTipEditTunnel" = "Edit tunnel (⌘E)"; "macToolTipToggleStatus" = "Toggle status (⌘T)"; + +// Mac log view + +"macLogColumnTitleTime" = "Time"; +"macLogColumnTitleLogMessage" = "Log message"; +"macLogButtonTitleClose" = "Close"; +"macLogButtonTitleSave" = "Save…"; diff --git a/WireGuard/WireGuard/UI/macOS/View/LogViewCell.swift b/WireGuard/WireGuard/UI/macOS/View/LogViewCell.swift new file mode 100644 index 0000000..1e2312a --- /dev/null +++ b/WireGuard/WireGuard/UI/macOS/View/LogViewCell.swift @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class LogViewCell: NSTextField { + init() { + super.init(frame: .zero) + isSelectable = false + isEditable = false + isBordered = false + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + stringValue = "" + preferredMaxLayoutWidth = 0 + } +} + +class LogViewTimestampCell: LogViewCell { + override init() { + super.init() + maximumNumberOfLines = 1 + lineBreakMode = .byClipping + preferredMaxLayoutWidth = 0 + setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + setContentHuggingPriority(.defaultLow, for: .vertical) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class LogViewMessageCell: LogViewCell { + override init() { + super.init() + maximumNumberOfLines = 0 + lineBreakMode = .byWordWrapping + setContentCompressionResistancePriority(.required, for: .vertical) + setContentHuggingPriority(.required, for: .vertical) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WireGuard/WireGuard/UI/macOS/ViewController/LogViewController.swift b/WireGuard/WireGuard/UI/macOS/ViewController/LogViewController.swift new file mode 100644 index 0000000..0816fbc --- /dev/null +++ b/WireGuard/WireGuard/UI/macOS/ViewController/LogViewController.swift @@ -0,0 +1,244 @@ +// 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 + + 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) + closeButton.isEnabled = false + + saveButton.target = self + saveButton.action = #selector(saveClicked) + saveButton.isEnabled = false + + let clipView = NSClipView() + clipView.documentView = tableView + scrollView.contentView = clipView + + 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(equalToConstant: 640), + 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.closeButton.isEnabled = true + self.saveButton.isEnabled = true + } + guard !fetchedLogEntries.isEmpty else { return } + let numOfEntries = self.logEntries.count + let lastVisibleRowIndex = self.tableView.row(at: NSPoint(x: 0, y: self.scrollView.contentView.documentVisibleRect.maxY - 1)) + let isScrolledToEnd = lastVisibleRowIndex == numOfEntries - 1 + self.logEntries.append(contentsOf: fetchedLogEntries) + self.tableView.insertRows(at: IndexSet(integersIn: numOfEntries ..< numOfEntries + fetchedLogEntries.count), withAnimation: .slideDown) + if isScrolledToEnd { + self.tableView.scrollRowToVisible(self.logEntries.count - 1) + } + } + } + + 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) + } + + @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 + self?.dismiss(self) + } + } + + } + } + + @objc func closeClicked() { + 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.stringValue = logEntries[row].timestamp + return cell + } else if LogColumn.logMessage.isRepresenting(tableColumn: tableColumn) { + let cell: LogViewMessageCell = tableView.dequeueReusableCell() + cell.stringValue = logEntries[row].message + cell.preferredMaxLayoutWidth = tableColumn?.width ?? 0 + return cell + } else { + fatalError() + } + } +} diff --git a/WireGuard/WireGuard/UI/macOS/ViewController/TunnelsListTableViewController.swift b/WireGuard/WireGuard/UI/macOS/ViewController/TunnelsListTableViewController.swift index 167aa0a..b694f3d 100644 --- a/WireGuard/WireGuard/UI/macOS/ViewController/TunnelsListTableViewController.swift +++ b/WireGuard/WireGuard/UI/macOS/ViewController/TunnelsListTableViewController.swift @@ -53,7 +53,7 @@ class TunnelsListTableViewController: NSViewController { let menu = NSMenu() menu.addItem(imageItem) - menu.addItem(withTitle: tr("macMenuExportLog"), action: #selector(handleExportLogAction), keyEquivalent: "") + menu.addItem(withTitle: tr("macMenuViewLog"), action: #selector(handleViewLogAction), keyEquivalent: "") menu.addItem(withTitle: tr("macMenuExportTunnels"), action: #selector(handleExportTunnelsAction), keyEquivalent: "") menu.autoenablesItems = false @@ -190,32 +190,9 @@ class TunnelsListTableViewController: NSViewController { } } - @objc func handleExportLogAction() { - guard let window = view.window else { return } - 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: window) { response in - guard response == .OK else { return } - guard let destinationURL = savePanel.url else { return } - - DispatchQueue.global(qos: .userInitiated).async { - 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 - } - } - - } + @objc func handleViewLogAction() { + let logVC = LogViewController() + self.presentAsSheet(logVC) } @objc func handleExportTunnelsAction() {