diff options
Diffstat (limited to 'Sources/WireGuardApp/UI/macOS/View')
11 files changed, 1585 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift b/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift new file mode 100644 index 0000000..98e4d49 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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 statusObservationToken: AnyObject? + var isOnDemandEnabledObservationToken: AnyObject? + var hasOnDemandRulesObservationToken: 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 + statusObservationToken = nil + isOnDemandEnabledObservationToken = nil + hasOnDemandRulesObservationToken = nil + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift new file mode 100644 index 0000000..d08503a --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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 + ] +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift new file mode 100644 index 0000000..574859d --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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 = 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.utf16.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 + let string = backingStore.string + enum FieldType: String { + case dns + case allowedips + } + var fieldType: FieldType? + resetLastPeer() + while spans.pointee.type != HighlightEnd { + let span = spans.pointee + var substring = String(string.substring(higlightSpan: span)).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 { + let delimiter = string.substring(higlightSpan: next.pointee) + let cidr = string.substring(higlightSpan: nextnext.pointee) + substring += delimiter + cidr + } + 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 string = backingStore.string + let fullTextRange = NSRange(..<string.endIndex, in: string) + + backingStore.beginEditing() + let defaultAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColorTheme.defaultColor, + .font: defaultFont + ] + backingStore.setAttributes(defaultAttributes, range: fullTextRange) + var spans = highlight_config(string)! + evaluateExcludePrivateIPs(highlightSpans: spans) + + let spansStart = spans + while spans.pointee.type != HighlightEnd { + let span = spans.pointee + + let startIndex = string.utf8.index(string.startIndex, offsetBy: span.start) + let endIndex = string.utf8.index(startIndex, offsetBy: span.len) + let range = NSRange(startIndex..<endIndex, in: string) + + 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 = String(string.substring(higlightSpan: span)) + } + + spans = spans.successor() + } + backingStore.endEditing() + free(spansStart) + + beginEditing() + edited(.editedAttributes, range: fullTextRange, changeInLength: 0) + endEditing() + } + +} + +private extension String { + func substring(higlightSpan span: highlight_span) -> Substring { + let startIndex = self.utf8.index(self.utf8.startIndex, offsetBy: span.start) + let endIndex = self.utf8.index(startIndex, offsetBy: span.len) + + return self[startIndex..<endIndex] + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift new file mode 100644 index 0000000..eafaf12 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class ConfTextView: NSTextView { + + private let confTextStorage = ConfTextStorage() + + @objc dynamic var hasError = 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(..<string.endIndex, in: string) + if shouldChangeText(in: fullTextRange, replacementString: text) { + replaceCharacters(in: fullTextRange, with: text) + didChangeText() + } + } +} + +extension ConfTextView: NSTextViewDelegate { + + func textDidChange(_ notification: Notification) { + confTextStorage.highlightSyntax() + updateConfigData() + needsDisplay = true + } + +} diff --git a/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift b/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift new file mode 100644 index 0000000..59cc556 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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 } + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift b/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift new file mode 100644 index 0000000..2ce31a6 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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 statusObservationToken: AnyObject? + var isOnDemandEnabledObservationToken: AnyObject? + var hasOnDemandRulesObservationToken: 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 + statusObservationToken = nil + isOnDemandEnabledObservationToken = nil + hasOnDemandRulesObservationToken = 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") + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift b/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift new file mode 100644 index 0000000..50eb2bd --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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") + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift b/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift new file mode 100644 index 0000000..fb9ff35 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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() } ?? [] +} diff --git a/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift b/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift new file mode 100644 index 0000000..9f844b1 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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) + statusObservationToken = tunnel?.observe(\TunnelContainer.status) { [weak self] tunnel, _ in + self?.statusImageView.image = TunnelListRow.image(for: tunnel) + } + isOnDemandEnabledObservationToken = tunnel?.observe(\TunnelContainer.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + self?.statusImageView.image = TunnelListRow.image(for: tunnel) + } + } + } + + 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? + private var isOnDemandEnabledObservationToken: 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 tunnel: TunnelContainer?) -> NSImage? { + guard let tunnel = tunnel else { return nil } + switch tunnel.status { + case .active, .restarting, .reasserting: + return NSImage(named: NSImage.statusAvailableName) + case .activating, .waiting, .deactivating: + return NSImage(named: NSImage.statusPartiallyAvailableName) + case .inactive: + if tunnel.isActivateOnDemandEnabled { + return NSImage(named: NSImage.Name.statusOnDemandEnabled) + } else { + return NSImage(named: NSImage.statusNoneName) + } + } + } + + override func prepareForReuse() { + nameLabel.stringValue = "" + statusImageView.image = nil + } +} + +extension NSImage.Name { + static let statusOnDemandEnabled = NSImage.Name("StatusCircleYellow") +} diff --git a/Sources/WireGuardApp/UI/macOS/View/highlighter.c b/Sources/WireGuardApp/UI/macOS/View/highlighter.c new file mode 100644 index 0000000..625b7e0 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/highlighter.c @@ -0,0 +1,641 @@ +// SPDX-License-Identifier: MIT +/* + * 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); +} + +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: + if (is_valid_ipv4(s) || is_valid_ipv6(s)) + append_highlight_span(ret, parent.s, s, HighlightIP); + else if (is_valid_hostname(s)) + append_highlight_span(ret, parent.s, s, HighlightHost); + else + append_highlight_span(ret, parent.s, s, 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; +} diff --git a/Sources/WireGuardApp/UI/macOS/View/highlighter.h b/Sources/WireGuardApp/UI/macOS/View/highlighter.h new file mode 100644 index 0000000..8b86acb --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/highlighter.h @@ -0,0 +1,38 @@ +/* SPDX-License-Identifier: MIT */ +/* + * 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); |