484 lines
19 KiB
Swift
484 lines
19 KiB
Swift
//
|
|
// MenuItemFactory.swift
|
|
// ClashX
|
|
//
|
|
// Created by CYC on 2018/8/4.
|
|
// Copyright © 2018年 yichengchen. All rights reserved.
|
|
//
|
|
|
|
import Cocoa
|
|
import RxCocoa
|
|
import SwiftyJSON
|
|
|
|
class MenuItemFactory {
|
|
private static var cachedProxyData: ClashProxyResp?
|
|
|
|
static var useViewToRenderProxy: Bool = UserDefaults.standard.object(forKey: "useViewToRenderProxy") as? Bool ?? AppDelegate.isAboveMacOS152 {
|
|
didSet {
|
|
UserDefaults.standard.set(useViewToRenderProxy, forKey: "useViewToRenderProxy")
|
|
}
|
|
}
|
|
|
|
static var hideUnselectable: Int = UserDefaults.standard.object(forKey: "hideUnselectable") as? Int ?? NSControl.StateValue.off.rawValue {
|
|
didSet {
|
|
UserDefaults.standard.set(hideUnselectable, forKey: "hideUnselectable")
|
|
recreateProxyMenuItems()
|
|
}
|
|
}
|
|
|
|
static var useYacdDashboard: Bool = UserDefaults.standard.object(forKey: "useYacdDashboard") as? Bool ?? false {
|
|
didSet {
|
|
UserDefaults.standard.set(useYacdDashboard, forKey: "useYacdDashboard")
|
|
DashboardManager.shared.useYacd = useYacdDashboard
|
|
}
|
|
}
|
|
|
|
static var useAlphaCore: Bool = UserDefaults.standard.object(forKey: "useAlphaCore") as? Bool ?? false {
|
|
didSet {
|
|
UserDefaults.standard.set(useAlphaCore, forKey: "useAlphaCore")
|
|
}
|
|
}
|
|
|
|
static let updateAllProvidersTitle = NSLocalizedString("Update All Providers", comment: "")
|
|
|
|
// MARK: - Public
|
|
|
|
static func refreshExistingMenuItems() {
|
|
ApiRequest.getMergedProxyData {
|
|
info in
|
|
if info?.proxiesMap.keys != cachedProxyData?.proxiesMap.keys {
|
|
// force update menu
|
|
refreshMenuItems(mergedData: info)
|
|
return
|
|
}
|
|
|
|
for proxy in info?.proxies ?? [] {
|
|
NotificationCenter.default.post(name: .proxyUpdate(for: proxy.name), object: proxy, userInfo: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func recreateProxyMenuItems() {
|
|
ApiRequest.getMergedProxyData {
|
|
proxyInfo in
|
|
cachedProxyData = proxyInfo
|
|
refreshMenuItems(mergedData: proxyInfo)
|
|
}
|
|
}
|
|
|
|
static func recreateRuleProvidersMenuItems() {
|
|
ApiRequest.requestRuleProviderList {
|
|
refreshRuleProviderMenuItems($0.allProviders.map({ $0.value }))
|
|
}
|
|
}
|
|
|
|
static func refreshMenuItems(mergedData proxyInfo: ClashProxyResp?) {
|
|
let leftPadding = AppDelegate.shared.hasMenuSelected()
|
|
guard let proxyInfo = proxyInfo else { return }
|
|
|
|
let hideState = NSControl.StateValue(rawValue: hideUnselectable)
|
|
|
|
var menuItems = [NSMenuItem]()
|
|
var collapsedItems = [NSMenuItem]()
|
|
|
|
for proxy in proxyInfo.proxyGroups {
|
|
var menu: NSMenuItem?
|
|
switch proxy.type {
|
|
case .select: menu = generateSelectorMenuItem(proxyGroup: proxy, proxyInfo: proxyInfo, leftPadding: leftPadding)
|
|
case .urltest, .fallback: menu = generateUrlTestFallBackMenuItem(proxyGroup: proxy, proxyInfo: proxyInfo, leftPadding: leftPadding)
|
|
case .loadBalance:
|
|
menu = generateLoadBalanceMenuItem(proxyGroup: proxy, proxyInfo: proxyInfo, leftPadding: leftPadding)
|
|
case .relay:
|
|
menu = generateListOnlyMenuItem(proxyGroup: proxy, proxyInfo: proxyInfo)
|
|
default: continue
|
|
}
|
|
|
|
guard let menu = menu else {
|
|
continue
|
|
}
|
|
|
|
switch hideState {
|
|
case .mixed where [.urltest, .fallback, .loadBalance, .relay].contains(proxy.type):
|
|
collapsedItems.append(menu)
|
|
menu.isEnabled = true
|
|
case .on where [.urltest, .fallback, .loadBalance, .relay].contains(proxy.type):
|
|
continue
|
|
default:
|
|
menuItems.append(menu)
|
|
menu.isEnabled = true
|
|
}
|
|
}
|
|
|
|
if hideState == .mixed {
|
|
let collapsedItem = NSMenuItem(title: "Collapsed", action: nil, keyEquivalent: "")
|
|
collapsedItem.isEnabled = true
|
|
collapsedItem.submenu = .init(title: "")
|
|
collapsedItem.submenu?.items = collapsedItems
|
|
|
|
menuItems.append(collapsedItem)
|
|
}
|
|
|
|
let items = Array(menuItems.reversed())
|
|
updateProxyList(withMenus: items)
|
|
|
|
refreshProxyProviderMenuItems(mergedData: proxyInfo)
|
|
recreateRuleProvidersMenuItems()
|
|
}
|
|
|
|
static func generateSwitchConfigMenuItems(complete: @escaping (([NSMenuItem]) -> Void)) {
|
|
let generateMenuItem: ((String) -> NSMenuItem) = {
|
|
config in
|
|
let item = NSMenuItem(title: config, action: #selector(MenuItemFactory.actionSelectConfig(sender:)), keyEquivalent: "")
|
|
item.target = MenuItemFactory.self
|
|
item.state = ConfigManager.selectConfigName == config ? .on : .off
|
|
return item
|
|
}
|
|
|
|
if RemoteControlManager.selectConfig != nil {
|
|
complete([])
|
|
return
|
|
}
|
|
|
|
if ICloudManager.shared.useiCloud.value {
|
|
ICloudManager.shared.getConfigFilesList {
|
|
complete($0.map { generateMenuItem($0) })
|
|
}
|
|
} else {
|
|
complete(ConfigManager.getConfigFilesList().map { generateMenuItem($0) })
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
// MARK: Updaters
|
|
|
|
static func updateProxyList(withMenus menus: [NSMenuItem]) {
|
|
let app = AppDelegate.shared
|
|
let startIndex = app.statusMenu.items.firstIndex(of: app.separatorLineTop)! + 1
|
|
let endIndex = app.statusMenu.items.firstIndex(of: app.sepatatorLineEndProxySelect)!
|
|
app.sepatatorLineEndProxySelect.isHidden = menus.count == 0
|
|
for _ in 0..<endIndex - startIndex {
|
|
app.statusMenu.removeItem(at: startIndex)
|
|
}
|
|
for each in menus {
|
|
app.statusMenu.insertItem(each, at: startIndex)
|
|
}
|
|
}
|
|
|
|
// MARK: Generators
|
|
|
|
private static func generateSelectorMenuItem(proxyGroup: ClashProxy,
|
|
proxyInfo: ClashProxyResp,
|
|
leftPadding: Bool) -> NSMenuItem? {
|
|
let proxyMap = proxyInfo.proxiesMap
|
|
|
|
let isGlobalMode = ConfigManager.shared.currentConfig?.mode == .global
|
|
if !isGlobalMode {
|
|
if proxyGroup.name == "GLOBAL" { return nil }
|
|
}
|
|
|
|
let menu = NSMenuItem(title: proxyGroup.name, action: nil, keyEquivalent: "")
|
|
let selectedName = proxyGroup.now ?? ""
|
|
if !ConfigManager.shared.disableShowCurrentProxyInMenu {
|
|
menu.view = ProxyGroupMenuItemView(group: proxyGroup.name, targetProxy: selectedName, hasLeftPadding: leftPadding)
|
|
}
|
|
let submenu = ProxyGroupMenu(title: proxyGroup.name)
|
|
|
|
for proxy in proxyGroup.all ?? [] {
|
|
guard let proxyModel = proxyMap[proxy] else { continue }
|
|
let proxyItem = ProxyMenuItem(proxy: proxyModel,
|
|
group: proxyGroup,
|
|
action: #selector(MenuItemFactory.actionSelectProxy(sender:)))
|
|
proxyItem.target = MenuItemFactory.self
|
|
submenu.add(delegate: proxyItem)
|
|
submenu.addItem(proxyItem)
|
|
}
|
|
|
|
if proxyGroup.isSpeedTestable && useViewToRenderProxy {
|
|
submenu.minimumWidth = proxyGroup.maxProxyNameLength + ProxyItemView.fixedPlaceHolderWidth
|
|
}
|
|
|
|
addSpeedTestMenuItem(submenu, proxyGroup: proxyGroup)
|
|
menu.submenu = submenu
|
|
return menu
|
|
}
|
|
|
|
private static func generateUrlTestFallBackMenuItem(proxyGroup: ClashProxy,
|
|
proxyInfo: ClashProxyResp,
|
|
leftPadding: Bool) -> NSMenuItem? {
|
|
let proxyMap = proxyInfo.proxiesMap
|
|
let selectedName = proxyGroup.now ?? ""
|
|
let menu = NSMenuItem(title: proxyGroup.name, action: nil, keyEquivalent: "")
|
|
if !ConfigManager.shared.disableShowCurrentProxyInMenu {
|
|
menu.view = ProxyGroupMenuItemView(group: proxyGroup.name, targetProxy: selectedName, hasLeftPadding: leftPadding)
|
|
}
|
|
let submenu = NSMenu(title: proxyGroup.name)
|
|
|
|
for proxyName in proxyGroup.all ?? [] {
|
|
guard let proxy = proxyMap[proxyName] else { continue }
|
|
let proxyMenuItem = ProxyMenuItem(proxy: proxy, group: proxyGroup, action: #selector(empty), simpleItem: true)
|
|
proxyMenuItem.target = MenuItemFactory.self
|
|
if proxy.name == selectedName {
|
|
proxyMenuItem.state = .on
|
|
}
|
|
|
|
proxyMenuItem.submenu = ProxyDelayHistoryMenu(proxy: proxy)
|
|
|
|
submenu.addItem(proxyMenuItem)
|
|
}
|
|
addSpeedTestMenuItem(submenu, proxyGroup: proxyGroup)
|
|
menu.submenu = submenu
|
|
return menu
|
|
}
|
|
|
|
private static func addSpeedTestMenuItem(_ menu: NSMenu, proxyGroup: ClashProxy) {
|
|
guard proxyGroup.speedtestAble.count > 0 else { return }
|
|
let speedTestItem = ProxyGroupSpeedTestMenuItem(group: proxyGroup)
|
|
let separator = NSMenuItem.separator()
|
|
menu.insertItem(separator, at: 0)
|
|
menu.insertItem(speedTestItem, at: 0)
|
|
(menu as? ProxyGroupMenu)?.add(delegate: speedTestItem)
|
|
}
|
|
|
|
private static func generateLoadBalanceMenuItem(proxyGroup: ClashProxy, proxyInfo: ClashProxyResp, leftPadding: Bool) -> NSMenuItem? {
|
|
let proxyMap = proxyInfo.proxiesMap
|
|
|
|
let menu = NSMenuItem(title: proxyGroup.name, action: nil, keyEquivalent: "")
|
|
if !ConfigManager.shared.disableShowCurrentProxyInMenu {
|
|
menu.view = ProxyGroupMenuItemView(group: proxyGroup.name, targetProxy: NSLocalizedString("Load Balance", comment: ""), hasLeftPadding: leftPadding, observeUpdate: false)
|
|
}
|
|
let submenu = ProxyGroupMenu(title: proxyGroup.name)
|
|
|
|
for proxy in proxyGroup.all ?? [] {
|
|
guard let proxyModel = proxyMap[proxy] else { continue }
|
|
let proxyItem = ProxyMenuItem(proxy: proxyModel,
|
|
group: proxyGroup,
|
|
action: #selector(empty))
|
|
proxyItem.target = MenuItemFactory.self
|
|
submenu.add(delegate: proxyItem)
|
|
submenu.addItem(proxyItem)
|
|
}
|
|
if proxyGroup.isSpeedTestable && useViewToRenderProxy {
|
|
submenu.minimumWidth = proxyGroup.maxProxyNameLength + ProxyItemView.fixedPlaceHolderWidth
|
|
}
|
|
addSpeedTestMenuItem(submenu, proxyGroup: proxyGroup)
|
|
menu.submenu = submenu
|
|
|
|
return menu
|
|
}
|
|
|
|
private static func generateListOnlyMenuItem(proxyGroup: ClashProxy, proxyInfo: ClashProxyResp) -> NSMenuItem? {
|
|
let menu = NSMenuItem(title: proxyGroup.name, action: nil, keyEquivalent: "")
|
|
let submenu = ProxyGroupMenu(title: proxyGroup.name)
|
|
let proxyMap = proxyInfo.proxiesMap
|
|
|
|
for proxy in proxyGroup.all ?? [] {
|
|
guard let proxyModel = proxyMap[proxy] else { continue }
|
|
let proxyItem = ProxyMenuItem(proxy: proxyModel,
|
|
group: proxyGroup,
|
|
action: #selector(empty),
|
|
simpleItem: true)
|
|
proxyItem.target = MenuItemFactory.self
|
|
submenu.add(delegate: proxyItem)
|
|
submenu.addItem(proxyItem)
|
|
}
|
|
menu.submenu = submenu
|
|
return menu
|
|
}
|
|
}
|
|
|
|
// MARK: - Experimental
|
|
|
|
extension MenuItemFactory {
|
|
static func addExperimentalMenuItem(_ menu: inout NSMenu) {
|
|
let useViewRender = NSMenuItem(title: NSLocalizedString("Enhance proxy list render", comment: ""), action: #selector(optionUseViewRenderMenuItemTap(sender:)), keyEquivalent: "")
|
|
useViewRender.target = self
|
|
menu.addItem(useViewRender)
|
|
updateUseViewRenderMenuItem(useViewRender)
|
|
}
|
|
|
|
static func updateUseViewRenderMenuItem(_ item: NSMenuItem) {
|
|
item.state = useViewToRenderProxy ? .on : .off
|
|
}
|
|
|
|
@objc static func optionUseViewRenderMenuItemTap(sender: NSMenuItem) {
|
|
useViewToRenderProxy = !useViewToRenderProxy
|
|
updateUseViewRenderMenuItem(sender)
|
|
recreateProxyMenuItems()
|
|
}
|
|
}
|
|
|
|
// MARK: - Meta
|
|
|
|
extension MenuItemFactory {
|
|
|
|
static func refreshProxyProviderMenuItems(mergedData proxyInfo: ClashProxyResp?) {
|
|
let app = AppDelegate.shared
|
|
guard let proxyInfo = proxyInfo,
|
|
let menu = app.proxyProvidersMenu,
|
|
let providers = proxyInfo.enclosingProviderResp
|
|
else { return }
|
|
|
|
let proxyProviders = providers.allProviders.filter {
|
|
$0.value.vehicleType == .HTTP
|
|
}.values.sorted(by: { $0.name < $1.name })
|
|
|
|
let isEmpty = proxyProviders.count == 0
|
|
app.proxyProvidersMenuItem.isEnabled = !isEmpty
|
|
guard !isEmpty else { return }
|
|
|
|
initUpdateAllProvidersMenuItem(for: menu, type: .proxy)
|
|
let maxNameLength = maxProvidersLength(for: proxyProviders.map({ $0.name }))
|
|
proxyProviders.forEach { provider in
|
|
let item = DualTitleMenuItem(
|
|
provider.name,
|
|
subTitle: providerUpdateTitle(provider.updatedAt),
|
|
action: #selector(actionUpdateSelectProvider),
|
|
maxLength: maxNameLength)
|
|
item.tag = ApiRequest.ProviderType.proxy.rawValue
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
}
|
|
|
|
static func refreshRuleProviderMenuItems(_ ruleProviders: [ClashRuleProvider]) {
|
|
let app = AppDelegate.shared
|
|
let isEmpty = ruleProviders.count == 0
|
|
app.ruleProvidersMenuItem.isEnabled = !isEmpty
|
|
|
|
guard !isEmpty,
|
|
let menu = app.ruleProvidersMenu
|
|
else { return }
|
|
|
|
initUpdateAllProvidersMenuItem(for: menu, type: .rule)
|
|
let maxNameLength = maxProvidersLength(for: ruleProviders.map({ $0.name }))
|
|
ruleProviders.sorted(by: { $0.name < $1.name })
|
|
.forEach { provider in
|
|
let item = DualTitleMenuItem(
|
|
provider.name,
|
|
subTitle: providerUpdateTitle(provider.updatedAt),
|
|
action: #selector(actionUpdateSelectProvider),
|
|
maxLength: maxNameLength)
|
|
item.tag = ApiRequest.ProviderType.rule.rawValue
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
}
|
|
|
|
static func initUpdateAllProvidersMenuItem(for menu: NSMenu, type: ApiRequest.ProviderType) {
|
|
if menu.items.count > 1 {
|
|
menu.items.enumerated().filter {
|
|
$0.offset > 1
|
|
}.forEach {
|
|
menu.removeItem($0.element)
|
|
}
|
|
} else {
|
|
let updateAllItem = NSMenuItem(title: updateAllProvidersTitle, action: #selector(actionUpdateAllProviders), keyEquivalent: "")
|
|
updateAllItem.tag = type.rawValue
|
|
updateAllItem.target = self
|
|
menu.addItem(updateAllItem)
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
|
|
static func maxProvidersLength(for names: [String]) -> CGFloat {
|
|
func getLength(_ string: String) -> CGFloat {
|
|
let rect = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 20)
|
|
let attr = [NSAttributedString.Key.font: NSFont.menuBarFont(ofSize: 14)]
|
|
let length = (string as NSString)
|
|
.boundingRect(with: rect,
|
|
options: .usesLineFragmentOrigin,
|
|
attributes: attr).width
|
|
return length
|
|
}
|
|
|
|
var lengths = names.map {
|
|
getLength($0) + 65
|
|
}
|
|
lengths.append(getLength(updateAllProvidersTitle))
|
|
return lengths.max() ?? 0
|
|
}
|
|
|
|
static func providerUpdateTitle(_ updatedAt: String?) -> String? {
|
|
let dateCF = DateComponentsFormatter()
|
|
dateCF.allowedUnits = [.day, .hour, .minute]
|
|
dateCF.maximumUnitCount = 1
|
|
dateCF.unitsStyle = .abbreviated
|
|
dateCF.zeroFormattingBehavior = .dropAll
|
|
|
|
guard let dateStr = updatedAt,
|
|
let date = DateFormatter.provider.date(from: dateStr),
|
|
!date.timeIntervalSinceNow.isNaN,
|
|
!date.timeIntervalSinceNow.isInfinite,
|
|
let re = dateCF.string(from: abs(date.timeIntervalSinceNow)) else { return nil }
|
|
|
|
return re + NSLocalizedString(" ago", comment: "Provider update time title")
|
|
}
|
|
|
|
@objc static func actionUpdateAllProviders(sender: NSMenuItem) {
|
|
let type = ApiRequest.ProviderType(rawValue: sender.tag)!
|
|
let s = "Update All \(type.logString()) Providers"
|
|
Logger.log(s)
|
|
ApiRequest.updateAllProviders(for: type) {
|
|
Logger.log("\(s) \($0) failed")
|
|
let info = $0 == 0 ? "Success" : "\($0) failed"
|
|
NSUserNotificationCenter.default.post(title: s, info: info)
|
|
recreateProxyMenuItems()
|
|
}
|
|
}
|
|
|
|
@objc static func actionUpdateSelectProvider(sender: DualTitleMenuItem) {
|
|
let name = sender.originTitle
|
|
let type = ApiRequest.ProviderType(rawValue: sender.tag)!
|
|
|
|
let log = "Update \(type.logString()) Provider \(name)"
|
|
Logger.log(log)
|
|
ApiRequest.updateProvider(for: type, name: name) {
|
|
let info = $0 ? "Success" : "Failed"
|
|
Logger.log("\(log) info")
|
|
NSUserNotificationCenter.default.post(title: log, info: info)
|
|
recreateProxyMenuItems()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Action
|
|
|
|
extension MenuItemFactory {
|
|
@objc static func actionSelectProxy(sender: ProxyMenuItem) {
|
|
guard let proxyGroup = sender.menu?.title else { return }
|
|
let proxyName = sender.proxyName
|
|
|
|
ApiRequest.updateProxyGroup(group: proxyGroup, selectProxy: proxyName) { success in
|
|
if success {
|
|
for items in sender.menu?.items ?? [NSMenuItem]() {
|
|
items.state = .off
|
|
}
|
|
sender.state = .on
|
|
// remember select proxy
|
|
let newModel = SavedProxyModel(group: proxyGroup, selected: proxyName, config: ConfigManager.selectConfigName)
|
|
ConfigManager.selectedProxyRecords.removeAll { model -> Bool in
|
|
return model.key == newModel.key
|
|
}
|
|
ConfigManager.selectedProxyRecords.append(newModel)
|
|
// terminal Connections for this group
|
|
ConnectionManager.closeConnection(for: proxyGroup)
|
|
// refresh menu items
|
|
MenuItemFactory.refreshExistingMenuItems()
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc static func actionSelectConfig(sender: NSMenuItem) {
|
|
let config = sender.title
|
|
AppDelegate.shared.updateConfig(configName: config, showNotification: false) {
|
|
err in
|
|
if err == nil {
|
|
ConnectionManager.closeAllConnection()
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc static func empty() {}
|
|
}
|