import AppKit import Combine import CoreText import Foundation import Libbox import Library @MainActor public class StatusBarController: NSObject, NSMenuDelegate { private var statusItem: NSStatusItem? private var menuIcon: NSImage? private var isIconOnlyMode = true private let environments: ExtensionEnvironments private var commandClient: CommandClient? private var cancellables = Set() private var statusCancellable: AnyCancellable? private var speedMode: MenuBarExtraSpeedMode = .enabled private var statusItemTitle: String? private var statusItemIsHighlighted = false private var statusItemTextModeSize: NSSize? private enum StatusItemLayout { static let horizontalPadding: CGFloat = 8 static let verticalPadding: CGFloat = 2 static let spacing: CGFloat = 6 static let lineHeight: CGFloat = 9 static let fontSize: CGFloat = 8.75 static let textWidth: CGFloat = 42 static let unifiedLineHeight: CGFloat = 11 static let unifiedFontSize: CGFloat = 10.5 } private var menu: NSMenu? private var headerItem: NSMenuItem? private var headerView: StatusBarHeaderView? private var urlTestAllItem: NSMenuItem? private var urlTestAllView: StatusBarURLTestView? private var urlTestGroupViews: [String: StatusBarURLTestView] = [:] private var groupMenuItems: [String: NSMenuItem] = [:] private var groupOutboundItems: [String: [String: NSMenuItem]] = [:] private var groupOrder: [String] = [] private var outboundOrderByGroup: [String: [String]] = [:] private var groupsItem: NSMenuItem? private var profilesItem: NSMenuItem? private var currentGroups: [LibboxOutboundGroup] = [] private var isURLTestingAll = false private var urlTestingGroups = Set() public init(environments: ExtensionEnvironments) { self.environments = environments super.init() observeProfile() Task { await initialize() } } private func initialize() async { let showMenuBarExtra = await SharedPreferences.showMenuBarExtra.get() speedMode = await MenuBarExtraSpeedMode(rawValue: SharedPreferences.menuBarExtraSpeedMode.get()) ?? .enabled updateVisibility(showMenuBarExtra) } public func updateVisibility(_ show: Bool) { if show { createStatusItem() } else { destroyStatusItem() } } public func updateSpeedMode(_ mode: Int) { speedMode = MenuBarExtraSpeedMode(rawValue: mode) ?? .enabled updateCommandClient() } private func createStatusItem() { guard statusItem == nil else { return } statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) let button = statusItem!.button! button.title = "" button.imagePosition = .imageOnly if let image = NSImage(named: "MenuIcon") { image.isTemplate = true menuIcon = image } button.image = renderStatusItemIconOnlyImage(highlighted: statusItemIsHighlighted) statusItem!.length = statusItemIconOnlyImageSize().width isIconOnlyMode = true menu = NSMenu() menu!.delegate = self statusItem!.menu = menu buildMenu() updateCommandClient() } private func destroyStatusItem() { if let statusItem { NSStatusBar.system.removeStatusItem(statusItem) self.statusItem = nil } menu = nil headerView = nil urlTestAllView = nil urlTestAllItem = nil urlTestGroupViews.removeAll() groupMenuItems.removeAll() groupOutboundItems.removeAll() groupOrder.removeAll() outboundOrderByGroup.removeAll() currentGroups = [] isURLTestingAll = false urlTestingGroups.removeAll() statusItemTitle = nil statusItemIsHighlighted = false statusItemTextModeSize = nil commandClient?.disconnect() commandClient = nil } private func buildMenu() { guard let menu else { return } menu.removeAllItems() headerView = StatusBarHeaderView(environments: environments) headerItem = NSMenuItem() headerItem!.view = headerView menu.addItem(headerItem!) groupsItem = NSMenuItem(title: NSLocalizedString("Group", comment: ""), action: nil, keyEquivalent: "") groupsItem!.submenu = NSMenu() groupsItem!.isHidden = true menu.addItem(groupsItem!) profilesItem = NSMenuItem(title: NSLocalizedString("Profile", comment: ""), action: nil, keyEquivalent: "") profilesItem!.submenu = NSMenu() menu.addItem(profilesItem!) menu.addItem(NSMenuItem.separator()) let openItem = NSMenuItem(title: NSLocalizedString("Open", comment: ""), action: #selector(openApp), keyEquivalent: "") openItem.target = self menu.addItem(openItem) let quitItem = NSMenuItem(title: NSLocalizedString("Quit", comment: ""), action: #selector(quitApp), keyEquivalent: "") quitItem.target = self menu.addItem(quitItem) Task { await loadProfiles() } } private func observeProfile() { environments.$extensionProfile .receive(on: DispatchQueue.main) .sink { [weak self] profile in self?.headerView?.updateProfile(profile) self?.observeProfileStatus(profile) self?.updateCommandClient() self?.updateGroupsVisibility() } .store(in: &cancellables) environments.profileUpdate .receive(on: DispatchQueue.main) .sink { [weak self] _ in Task { await self?.loadProfiles() } } .store(in: &cancellables) environments.selectedProfileUpdate .receive(on: DispatchQueue.main) .sink { [weak self] _ in Task { await self?.loadProfiles() } } .store(in: &cancellables) environments.commandClient.$groups .receive(on: DispatchQueue.main) .sink { [weak self] groups in self?.updateGroupsMenu(groups) } .store(in: &cancellables) } private func observeProfileStatus(_ profile: ExtensionProfile?) { statusCancellable = nil guard let profile else { return } statusCancellable = profile.$status .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateCommandClient() self?.updateGroupsVisibility() } } private func updateGroupsVisibility() { let isConnected = environments.extensionProfile?.status.isConnected == true groupsItem?.isHidden = !isConnected if isConnected { environments.commandClient.connect() } updateURLTestAvailability() } private func loadProfiles() async { guard let submenu = profilesItem?.submenu else { return } submenu.removeAllItems() let profileList: [ProfilePreview] do { profileList = try await ProfileManager.list().map { ProfilePreview($0) } } catch { return } if profileList.isEmpty { let emptyItem = NSMenuItem(title: NSLocalizedString("Empty profiles", comment: ""), action: nil, keyEquivalent: "") emptyItem.isEnabled = false submenu.addItem(emptyItem) return } var selectedProfileID = await SharedPreferences.selectedProfileID.get() if !profileList.contains(where: { $0.id == selectedProfileID }) { selectedProfileID = profileList[0].id await SharedPreferences.selectedProfileID.set(selectedProfileID) } for profile in profileList { let item = NSMenuItem(title: profile.name, action: #selector(selectProfile(_:)), keyEquivalent: "") item.target = self item.representedObject = profile.id item.state = profile.id == selectedProfileID ? .on : .off submenu.addItem(item) } } private func updateGroupsMenu(_ groups: [LibboxOutboundGroup]?) { currentGroups = groups ?? [] guard let submenu = groupsItem?.submenu else { return } let groups = currentGroups let selectableGroups = groups.filter(\.selectable) if selectableGroups.isEmpty { groupsItem?.isHidden = true updateURLTestAvailability() return } groupsItem?.isHidden = environments.extensionProfile?.status.isConnected != true let newGroupOrder = selectableGroups.map(\.tag) var newOutboundOrderByGroup: [String: [String]] = [:] for group in selectableGroups { var tags: [String] = [] let items = group.getItems()! while items.hasNext() { tags.append(items.next()!.tag) } newOutboundOrderByGroup[group.tag] = tags } let structureSame = newGroupOrder == groupOrder && newOutboundOrderByGroup == outboundOrderByGroup && urlTestAllView != nil && !groupMenuItems.isEmpty if structureSame { if !updateGroupMenuItems(selectableGroups) { rebuildGroupsMenu(selectableGroups, submenu: submenu) groupOrder = newGroupOrder outboundOrderByGroup = newOutboundOrderByGroup } } else { rebuildGroupsMenu(selectableGroups, submenu: submenu) groupOrder = newGroupOrder outboundOrderByGroup = newOutboundOrderByGroup } updateURLTestAvailability() } private func rebuildGroupsMenu(_ selectableGroups: [LibboxOutboundGroup], submenu: NSMenu) { submenu.removeAllItems() urlTestGroupViews.removeAll() groupMenuItems.removeAll() groupOutboundItems.removeAll() let urlTestAllView = StatusBarURLTestView(title: NSLocalizedString("URLTest", comment: "")) { [weak self] in self?.performURLTestForAllGroups() } let urlTestAllItem = NSMenuItem() urlTestAllItem.view = urlTestAllView self.urlTestAllView = urlTestAllView self.urlTestAllItem = urlTestAllItem submenu.addItem(urlTestAllItem) let closeAllItem = NSMenuItem( title: NSLocalizedString("Close All Connections", comment: ""), action: #selector(closeAllConnections), keyEquivalent: "" ) closeAllItem.target = self submenu.addItem(closeAllItem) submenu.addItem(NSMenuItem.separator()) let font = NSFont.menuFont(ofSize: 0) let attrs: [NSAttributedString.Key: Any] = [.font: font] for group in selectableGroups { let groupItem = NSMenuItem(title: group.tag, action: nil, keyEquivalent: "") let groupSubmenu = NSMenu() let groupURLTestView = StatusBarURLTestView(title: NSLocalizedString("URLTest", comment: "")) { [weak self] in self?.performURLTestForGroup(group.tag) } let groupURLTestItem = NSMenuItem() groupURLTestItem.view = groupURLTestView groupSubmenu.addItem(groupURLTestItem) groupSubmenu.addItem(NSMenuItem.separator()) urlTestGroupViews[group.tag] = groupURLTestView var outboundItemsByTag: [String: NSMenuItem] = [:] var outboundData: [(LibboxOutboundGroupItem, NSMenuItem)] = [] var maxTagWidth: CGFloat = 0 var maxDelayWidth: CGFloat = 0 let items = group.getItems()! while items.hasNext() { let outbound = items.next()! let outboundItem = NSMenuItem( title: outbound.tag, action: #selector(selectOutbound(_:)), keyEquivalent: "" ) outboundItem.target = self outboundItem.representedObject = ["groupTag": group.tag, "outboundTag": outbound.tag] outboundItem.state = group.selected == outbound.tag ? .on : .off outboundItemsByTag[outbound.tag] = outboundItem let tagWidth = (outbound.tag as NSString).size(withAttributes: attrs).width maxTagWidth = max(maxTagWidth, tagWidth) if outbound.urlTestDelay > 0 { let delayText = "\(outbound.urlTestDelay)ms" let delayWidth = (delayText as NSString).size(withAttributes: attrs).width maxDelayWidth = max(maxDelayWidth, delayWidth) } outboundData.append((outbound, outboundItem)) } let tabLocation = maxTagWidth + 20 + maxDelayWidth let tabStop = NSTextTab(textAlignment: .right, location: tabLocation) let style = NSMutableParagraphStyle() style.tabStops = [tabStop] for (outbound, outboundItem) in outboundData { let delay = outbound.urlTestDelay let delayText = delay > 0 ? "\(delay)ms" : "" let fullText = "\(outbound.tag)\t\(delayText)" let attrString = NSMutableAttributedString( string: fullText, attributes: [.font: font, .paragraphStyle: style] ) if delay > 0 { let color = NSColor.delayColor(for: UInt16(delay)) let delayStart = (fullText as NSString).length - (delayText as NSString).length attrString.addAttribute( .foregroundColor, value: color, range: NSRange(location: delayStart, length: (delayText as NSString).length) ) } outboundItem.attributedTitle = attrString groupSubmenu.addItem(outboundItem) } groupItem.submenu = groupSubmenu submenu.addItem(groupItem) groupMenuItems[group.tag] = groupItem groupOutboundItems[group.tag] = outboundItemsByTag } } private func updateGroupMenuItems(_ selectableGroups: [LibboxOutboundGroup]) -> Bool { let font = NSFont.menuFont(ofSize: 0) let attrs: [NSAttributedString.Key: Any] = [.font: font] for group in selectableGroups { guard let outboundItemsByTag = groupOutboundItems[group.tag] else { return false } var outboundData: [(LibboxOutboundGroupItem, NSMenuItem)] = [] var maxTagWidth: CGFloat = 0 var maxDelayWidth: CGFloat = 0 let items = group.getItems()! while items.hasNext() { let outbound = items.next()! guard let outboundItem = outboundItemsByTag[outbound.tag] else { return false } outboundItem.state = group.selected == outbound.tag ? .on : .off let tagWidth = (outbound.tag as NSString).size(withAttributes: attrs).width maxTagWidth = max(maxTagWidth, tagWidth) if outbound.urlTestDelay > 0 { let delayText = "\(outbound.urlTestDelay)ms" let delayWidth = (delayText as NSString).size(withAttributes: attrs).width maxDelayWidth = max(maxDelayWidth, delayWidth) } outboundData.append((outbound, outboundItem)) } let tabLocation = maxTagWidth + 20 + maxDelayWidth let tabStop = NSTextTab(textAlignment: .right, location: tabLocation) let style = NSMutableParagraphStyle() style.tabStops = [tabStop] for (outbound, outboundItem) in outboundData { let delay = outbound.urlTestDelay let delayText = delay > 0 ? "\(delay)ms" : "" let fullText = "\(outbound.tag)\t\(delayText)" let attrString = NSMutableAttributedString( string: fullText, attributes: [.font: font, .paragraphStyle: style] ) if delay > 0 { let color = NSColor.delayColor(for: UInt16(delay)) let delayStart = (fullText as NSString).length - (delayText as NSString).length attrString.addAttribute( .foregroundColor, value: color, range: NSRange(location: delayStart, length: (delayText as NSString).length) ) } outboundItem.attributedTitle = attrString } } return true } @objc private func selectProfile(_ sender: NSMenuItem) { guard let profileID = sender.representedObject as? Int64 else { return } Task { await SharedPreferences.selectedProfileID.set(profileID) environments.selectedProfileUpdate.send() if environments.extensionProfile?.status.isConnected == true { do { try await environments.extensionProfile?.reloadService() } catch { showAlert(error: error) } } await loadProfiles() } } @objc private func selectOutbound(_ sender: NSMenuItem) { guard let info = sender.representedObject as? [String: String], let groupTag = info["groupTag"], let outboundTag = info["outboundTag"] else { return } Task { do { try await LibboxNewStandaloneCommandClient()!.selectOutbound(groupTag, outboundTag: outboundTag) } catch { showAlert(error: error) } } } @objc private func closeAllConnections() { do { try LibboxNewStandaloneCommandClient()!.closeConnections() } catch { showAlert(error: error) } } @objc private func openApp() { NSApp.setActivationPolicy(.regular) if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) { window.makeKeyAndOrderFront(nil) } if let dockApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first { dockApp.activate() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { NSApp.activate(ignoringOtherApps: true) } } } @objc private func quitApp() { NSApp.terminate(nil) } private func updateCommandClient() { guard statusItem != nil else { return } let shouldConnect = speedMode != .disabled && environments.extensionProfile?.status.isConnectedStrict == true if shouldConnect { if commandClient == nil { commandClient = CommandClient(.status) commandClient!.statusPublisher .receive(on: DispatchQueue.main) .sink { [weak self] status in self?.updateSpeedDisplay(status: status) } .store(in: &cancellables) } commandClient!.connect() } else { commandClient?.disconnect() updateSpeedDisplay(status: nil) } } private func updateSpeedDisplay(status: LibboxStatusMessage?) { guard let statusItem, let button = statusItem.button else { return } let title: String if speedMode == .disabled || status == nil || !status!.trafficAvailable { title = "" } else if speedMode == .enabled { title = "\(LibboxFormatBytes(status!.uplink))/s\n\(LibboxFormatBytes(status!.downlink))/s" } else { title = "\(LibboxFormatBytes(status!.uplink + status!.downlink))/s" } let shouldUseIconOnly = title.isEmpty if shouldUseIconOnly != isIconOnlyMode { isIconOnlyMode = shouldUseIconOnly if shouldUseIconOnly { statusItemTitle = nil statusItemTextModeSize = nil button.image = renderStatusItemIconOnlyImage(highlighted: statusItemIsHighlighted) statusItem.length = statusItemIconOnlyImageSize().width } else { statusItemTitle = title let image = renderStatusItemImage( title: title, highlighted: statusItemIsHighlighted, unified: speedMode == .unified ) button.image = image if let image { statusItemTextModeSize = image.size statusItem.length = max(image.size.width, NSStatusBar.system.thickness) } } } else if !shouldUseIconOnly { if title != statusItemTitle { statusItemTitle = title button.image = renderStatusItemImage( title: title, highlighted: statusItemIsHighlighted, unified: speedMode == .unified ) } } } private func setStatusItemHighlighted(_ highlighted: Bool) { guard statusItemIsHighlighted != highlighted else { return } statusItemIsHighlighted = highlighted guard let button = statusItem?.button else { return } if isIconOnlyMode { button.image = renderStatusItemIconOnlyImage(highlighted: highlighted) return } guard let title = statusItemTitle else { return } button.image = renderStatusItemImage( title: title, highlighted: highlighted, unified: speedMode == .unified ) } private func renderStatusItemIconOnlyImage(highlighted: Bool) -> NSImage? { guard let icon = menuIcon else { return nil } let size = statusItemIconOnlyImageSize() let iconSize = statusItemIconSize(forHeight: size.height) guard iconSize.width > 0, iconSize.height > 0 else { return nil } let iconColor = highlighted ? NSColor.unemphasizedSelectedTextColor : NSColor.labelColor let image = NSImage(size: size, flipped: false) { _ in let iconRect = NSRect( x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2, width: iconSize.width, height: iconSize.height ) NSGraphicsContext.saveGraphicsState() iconColor.setFill() iconRect.fill() icon.draw( in: iconRect, from: .zero, operation: .destinationIn, fraction: 1, respectFlipped: true, hints: nil ) NSGraphicsContext.restoreGraphicsState() return true } image.isTemplate = false return image } private func renderStatusItemImage(title: String, highlighted: Bool, unified: Bool) -> NSImage? { let size = statusItemTextModeSize ?? statusItemImageSize() let textColor = highlighted ? NSColor.unemphasizedSelectedTextColor : NSColor.labelColor let iconColor = textColor let icon = menuIcon let iconSize = statusItemIconSize(forHeight: size.height) let lineHeight = unified ? StatusItemLayout.unifiedLineHeight : StatusItemLayout.lineHeight let fontSize = unified ? StatusItemLayout.unifiedFontSize : StatusItemLayout.fontSize let textX = StatusItemLayout.horizontalPadding + iconSize.width + (iconSize.width > 0 ? StatusItemLayout.spacing : 0) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.maximumLineHeight = lineHeight paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.alignment = .right paragraphStyle.lineBreakMode = .byClipping let attributes: [NSAttributedString.Key: Any] = [ .paragraphStyle: paragraphStyle, .font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: textColor, ] let attributedTitle = NSAttributedString(string: title, attributes: attributes) let textRect: NSRect if unified { textRect = NSRect(x: textX, y: 0, width: StatusItemLayout.textWidth, height: size.height) } else { let textBounds = attributedTitle.boundingRect( with: NSSize(width: StatusItemLayout.textWidth, height: size.height), options: [.usesLineFragmentOrigin, .usesFontLeading] ) let textHeight = min(ceil(textBounds.height), size.height) let textY = (size.height - textHeight) / 2 textRect = NSRect(x: textX, y: textY, width: StatusItemLayout.textWidth, height: textHeight) } let image = NSImage(size: size, flipped: false) { _ in if let icon, iconSize.width > 0 { let iconY = (size.height - iconSize.height) / 2 let iconRect = NSRect( x: StatusItemLayout.horizontalPadding, y: iconY, width: iconSize.width, height: iconSize.height ) NSGraphicsContext.saveGraphicsState() iconColor.setFill() iconRect.fill() icon.draw( in: iconRect, from: .zero, operation: .destinationIn, fraction: 1, respectFlipped: true, hints: nil ) NSGraphicsContext.restoreGraphicsState() } if unified, let context = NSGraphicsContext.current?.cgContext { let line = CTLineCreateWithAttributedString(attributedTitle) var ascent: CGFloat = 0 var descent: CGFloat = 0 var leading: CGFloat = 0 CTLineGetTypographicBounds(line, &ascent, &descent, &leading) let textHeight = ascent + descent let baselineY = (size.height - textHeight) / 2 + descent context.saveGState() context.textPosition = CGPoint(x: textRect.minX, y: baselineY) CTLineDraw(line, context) context.restoreGState() } else { attributedTitle.draw( with: textRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil ) } return true } image.isTemplate = false return image } private func statusItemImageSize() -> NSSize { let height = NSStatusBar.system.thickness let iconSize = statusItemIconSize(forHeight: height) let spacing = iconSize.width > 0 ? StatusItemLayout.spacing : 0 let width = StatusItemLayout.horizontalPadding * 2 + iconSize.width + spacing + StatusItemLayout.textWidth let size = NSSize(width: ceil(width), height: ceil(height)) statusItemTextModeSize = size return size } private func statusItemIconOnlyImageSize() -> NSSize { let height = NSStatusBar.system.thickness let iconSize = statusItemIconSize(forHeight: height) let contentWidth = StatusItemLayout.horizontalPadding * 2 + iconSize.width let width = max(ceil(contentWidth), NSStatusBar.system.thickness) return NSSize(width: width, height: ceil(height)) } private func statusItemIconSize(forHeight height: CGFloat) -> NSSize { guard let menuIcon else { return .zero } let maxIconHeight = height - StatusItemLayout.verticalPadding * 2 guard maxIconHeight > 0 else { return .zero } let scale = min(1, maxIconHeight / menuIcon.size.height) return NSSize(width: menuIcon.size.width * scale, height: menuIcon.size.height * scale) } private func showAlert(error: Error) { let alert = NSAlert() alert.messageText = NSLocalizedString("Error", comment: "") alert.informativeText = error.localizedDescription alert.alertStyle = .warning alert.addButton(withTitle: NSLocalizedString("Ok", comment: "")) alert.runModal() } private func updateURLTestAvailability() { let isConnected = environments.extensionProfile?.status.isConnected == true let hasGroups = currentGroups.contains(where: \.selectable) let isAnyGroupTesting = !urlTestingGroups.isEmpty let allEnabled = isConnected && hasGroups && !isURLTestingAll && !isAnyGroupTesting urlTestAllView?.setLoading(isURLTestingAll) urlTestAllView?.setEnabled(allEnabled) urlTestAllItem?.isHidden = !isConnected || !hasGroups for (tag, view) in urlTestGroupViews { let isGroupTesting = urlTestingGroups.contains(tag) view.setLoading(isGroupTesting) view.setEnabled(isConnected && !isURLTestingAll && !isGroupTesting) } } private func performURLTestForAllGroups() { let groups = currentGroups.filter(\.selectable) guard !groups.isEmpty else { return } guard environments.extensionProfile?.status.isConnected == true else { return } guard !isURLTestingAll else { return } setURLTestAllRunning(true) Task { do { let client = LibboxNewStandaloneCommandClient()! for group in groups { try await client.urlTest(group.tag) } } catch { await showAlert(error: error) } await MainActor.run { self.setURLTestAllRunning(false) } } } private func performURLTestForGroup(_ tag: String) { guard environments.extensionProfile?.status.isConnected == true else { return } guard !isURLTestingAll else { return } guard !urlTestingGroups.contains(tag) else { return } setURLTestGroupRunning(tag, true) Task { do { let client = LibboxNewStandaloneCommandClient()! try await client.urlTest(tag) } catch { await showAlert(error: error) } await MainActor.run { self.setURLTestGroupRunning(tag, false) } } } private func setURLTestAllRunning(_ running: Bool) { isURLTestingAll = running updateURLTestAvailability() } private func setURLTestGroupRunning(_ tag: String, _ running: Bool) { if running { urlTestingGroups.insert(tag) } else { urlTestingGroups.remove(tag) } updateURLTestAvailability() } // MARK: - NSMenuDelegate public func menuWillOpen(_: NSMenu) { headerView?.refresh() setStatusItemHighlighted(true) Task { await loadProfiles() } } public func menuDidClose(_: NSMenu) { setStatusItemHighlighted(false) } } // MARK: - StatusBarURLTestView @MainActor private class StatusBarURLTestView: NSView { private let titleLabel: NSTextField private let progressIndicator: NSProgressIndicator private let action: () -> Void private var isEnabled = true private var isLoading = false private var isHighlighted = false private var trackingArea: NSTrackingArea? init(title: String, action: @escaping () -> Void) { self.action = action titleLabel = NSTextField(labelWithString: title) progressIndicator = NSProgressIndicator() super.init(frame: NSRect(x: 0, y: 0, width: 250, height: 22)) setupView() } @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupView() { titleLabel.font = NSFont.menuFont(ofSize: 0) titleLabel.lineBreakMode = .byTruncatingTail titleLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) progressIndicator.style = .spinning progressIndicator.controlSize = .small progressIndicator.isDisplayedWhenStopped = false progressIndicator.translatesAutoresizingMaskIntoConstraints = false addSubview(progressIndicator) NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: progressIndicator.leadingAnchor, constant: -8), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), progressIndicator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), progressIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), ]) updateState() } func setEnabled(_ enabled: Bool) { isEnabled = enabled updateState() } func setLoading(_ loading: Bool) { isLoading = loading updateState() } private func updateState() { let canInteract = isEnabled && !isLoading if !canInteract { isHighlighted = false } if isHighlighted, canInteract { titleLabel.textColor = NSColor.selectedMenuItemTextColor } else { titleLabel.textColor = canInteract ? NSColor.labelColor : NSColor.disabledControlTextColor } if isLoading { progressIndicator.startAnimation(nil) } else { progressIndicator.stopAnimation(nil) } needsDisplay = true } override func updateTrackingAreas() { if let trackingArea { removeTrackingArea(trackingArea) } let area = NSTrackingArea( rect: bounds, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(area) trackingArea = area super.updateTrackingAreas() } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) guard isEnabled, !isLoading else { return } isHighlighted = true titleLabel.textColor = NSColor.selectedMenuItemTextColor needsDisplay = true } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) guard isEnabled, !isLoading else { return } isHighlighted = false titleLabel.textColor = NSColor.labelColor needsDisplay = true } override func mouseDown(with _: NSEvent) { guard isEnabled, !isLoading else { return } isHighlighted = true titleLabel.textColor = NSColor.selectedMenuItemTextColor needsDisplay = true } override func mouseUp(with event: NSEvent) { guard isEnabled, !isLoading else { return } let point = convert(event.locationInWindow, from: nil) if bounds.contains(point) { action() } isHighlighted = false titleLabel.textColor = NSColor.labelColor needsDisplay = true } override func draw(_ dirtyRect: NSRect) { if isHighlighted { NSColor.selectedMenuItemColor.setFill() dirtyRect.fill() } else { NSColor.clear.setFill() dirtyRect.fill() } super.draw(dirtyRect) } } // MARK: - StatusBarHeaderView @MainActor private class StatusBarHeaderView: NSView { private let environments: ExtensionEnvironments private let titleLabel: NSTextField private let statusSwitch: NSSwitch private let loadingIndicator: NSProgressIndicator private var cancellables = Set() init(environments: ExtensionEnvironments) { self.environments = environments titleLabel = NSTextField(labelWithString: "sing-box") statusSwitch = NSSwitch() loadingIndicator = NSProgressIndicator() super.init(frame: NSRect(x: 0, y: 0, width: 250, height: 36)) setupView() refresh() } @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupView() { titleLabel.font = .boldSystemFont(ofSize: 13) titleLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) statusSwitch.target = self statusSwitch.action = #selector(statusSwitchChanged) statusSwitch.translatesAutoresizingMaskIntoConstraints = false addSubview(statusSwitch) loadingIndicator.style = .spinning loadingIndicator.controlSize = .small loadingIndicator.translatesAutoresizingMaskIntoConstraints = false loadingIndicator.startAnimation(nil) addSubview(loadingIndicator) NSLayoutConstraint.activate([ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), statusSwitch.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), statusSwitch.centerYAnchor.constraint(equalTo: centerYAnchor), loadingIndicator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } func refresh() { Task { if environments.extensionProfile == nil { await environments.reload() } updateProfile(environments.extensionProfile) } } func updateProfile(_ profile: ExtensionProfile?) { loadingIndicator.isHidden = profile != nil statusSwitch.isHidden = profile == nil if let profile { statusSwitch.isEnabled = profile.status.isEnabled statusSwitch.state = profile.status.isConnected ? .on : .off observeProfileStatus(profile) } } private func observeProfileStatus(_ profile: ExtensionProfile) { cancellables.removeAll() profile.$status .receive(on: DispatchQueue.main) .sink { [weak self] status in self?.statusSwitch.isEnabled = status.isEnabled self?.statusSwitch.state = status.isConnected ? .on : .off } .store(in: &cancellables) } @objc private func statusSwitchChanged() { let isOn = statusSwitch.state == .on Task { do { if isOn { try await environments.extensionProfile?.start() } else { try await environments.extensionProfile?.stop() } } catch { statusSwitch.state = isOn ? .off : .on let alert = NSAlert() alert.messageText = NSLocalizedString("Error", comment: "") alert.informativeText = error.localizedDescription alert.alertStyle = .warning alert.addButton(withTitle: NSLocalizedString("Ok", comment: "")) alert.runModal() } } } } // MARK: - NSColor Extension extension NSColor { static func delayColor(for delay: UInt16) -> NSColor { switch delay { case 0: return .systemGray case ..<800: return .systemGreen case 800 ..< 1500: return .systemYellow default: return .systemOrange } } }