ClashX.Meta/ClashX/General/Managers/MenuItemFactory.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() {}
}