Files
sing-box-for-apple/MacLibrary/StatusBarController.swift
T
2026-02-15 16:39:17 +08:00

1088 lines
39 KiB
Swift

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<AnyCancellable>()
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<String>()
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<AnyCancellable>()
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
}
}
}