@@ -10,8 +10,72 @@ import SwiftUI
1010import Combine
1111import KeyboardShortcuts
1212
13+ final class PingMenuItemView : NSView {
14+ private let titleField = NSTextField ( labelWithString: " " )
15+ private let clickHandler : ( ) -> Void
16+ private var trackingAreaRef : NSTrackingArea ?
17+
18+ init ( title: String , clickHandler: @escaping ( ) -> Void ) {
19+ self . clickHandler = clickHandler
20+ super. init ( frame: NSRect ( x: 0 , y: 0 , width: 240 , height: 22 ) )
21+
22+ wantsLayer = true
23+ layer? . cornerRadius = 4
24+
25+ titleField. stringValue = title
26+ titleField. font = . menuFont( ofSize: 0 )
27+ titleField. textColor = . labelColor
28+ titleField. backgroundColor = . clear
29+ titleField. isBordered = false
30+ titleField. isEditable = false
31+ titleField. lineBreakMode = . byTruncatingTail
32+ titleField. translatesAutoresizingMaskIntoConstraints = false
33+
34+ addSubview ( titleField)
35+ NSLayoutConstraint . activate ( [
36+ // 对齐系统菜单项文本起始位置,给左侧预留勾选/图标槽位
37+ titleField. leadingAnchor. constraint ( equalTo: leadingAnchor, constant: 24 ) ,
38+ titleField. trailingAnchor. constraint ( equalTo: trailingAnchor, constant: - 12 ) ,
39+ titleField. centerYAnchor. constraint ( equalTo: centerYAnchor) ,
40+ ] )
41+ }
42+
43+ @available ( * , unavailable)
44+ required init ? ( coder: NSCoder ) {
45+ fatalError ( " init(coder:) has not been implemented " )
46+ }
47+
48+ override func updateTrackingAreas( ) {
49+ super. updateTrackingAreas ( )
50+ if let trackingAreaRef {
51+ removeTrackingArea ( trackingAreaRef)
52+ }
53+ let options : NSTrackingArea . Options = [ . activeAlways, . mouseEnteredAndExited, . inVisibleRect]
54+ let trackingArea = NSTrackingArea ( rect: bounds, options: options, owner: self , userInfo: nil )
55+ addTrackingArea ( trackingArea)
56+ trackingAreaRef = trackingArea
57+ }
58+
59+ override func mouseEntered( with event: NSEvent ) {
60+ layer? . backgroundColor = NSColor . selectedContentBackgroundColor. withAlphaComponent ( 0.18 ) . cgColor
61+ }
62+
63+ override func mouseExited( with event: NSEvent ) {
64+ layer? . backgroundColor = NSColor . clear. cgColor
65+ }
66+
67+ override func mouseDown( with event: NSEvent ) {
68+ clickHandler ( )
69+ }
70+
71+ func updateTitle( _ title: String ) {
72+ titleField. stringValue = title
73+ needsDisplay = true
74+ }
75+ }
76+
1377@MainActor
14- final class AppMenuManager : NSObject {
78+ final class AppMenuManager : NSObject , NSMenuDelegate {
1579 static let shared = AppMenuManager ( )
1680
1781 let versionController = AppVersionController ( )
@@ -28,6 +92,8 @@ final class AppMenuManager: NSObject {
2892 private var viewErrorLogItem : NSMenuItem !
2993 private var viewLogFilesItem : NSMenuItem !
3094 private var clearLogsItem : NSMenuItem !
95+ private var logsItem : NSMenuItem !
96+ private var logsSubMenu : NSMenu !
3197 private var pacModeItem : NSMenuItem !
3298 private var tunnelModeItem : NSMenuItem !
3399 private var globalModeItem : NSMenuItem !
@@ -51,15 +117,17 @@ final class AppMenuManager: NSObject {
51117 private var helpItem : NSMenuItem !
52118 private var quitItem : NSMenuItem !
53119 private var pingTip : String = " "
120+ private var pingItemView : PingMenuItemView !
54121 private let pingTipSubject = PassthroughSubject < String , Never > ( )
55122 private var cancellables = Set < AnyCancellable > ( )
123+ private weak var statusMenu : NSMenu ?
56124
57125 override private init ( ) {
58126 super. init ( )
59127 pingTipSubject // 500毫秒刷新一下,避免很多时一直刷新UI
60128 . throttle ( for: . milliseconds( 500 ) , scheduler: DispatchQueue . main, latest: true )
61129 . sink { [ weak self] tip in
62- self ? . pingItem ? . title = String ( localized : . LatencyTest ) + " \( tip) "
130+ self ? . setPingMenuTitle ( tip : tip)
63131 }
64132 . store ( in: & cancellables)
65133
@@ -194,7 +262,7 @@ final class AppMenuManager: NSObject {
194262 return
195263 }
196264 // coreStatusItem 使用 SwiftUI CoreStatusItemView 自动观察 AppState,无需手动更新 title
197- pingItem ? . title = String ( localized : . LatencyTest ) + " \( self . pingTip) "
265+ setPingMenuTitle ( tip : self . pingTip)
198266 diagnosticsItem? . title = String ( localized: . Diagnostics)
199267 toggleCoreItem? . title = AppState . shared. v2rayTurnOn ? String ( localized: . TurnCoreOff) : String ( localized: . TurnCoreOn)
200268 viewConfigItem? . title = String ( localized: . ViewConfigJson)
@@ -203,6 +271,7 @@ final class AppMenuManager: NSObject {
203271 viewErrorLogItem? . title = String ( localized: . ViewErrorLog)
204272 viewLogFilesItem? . title = String ( localized: . ViewLogFiles)
205273 clearLogsItem? . title = String ( localized: . ClearAllLogs)
274+ logsItem? . title = String ( localized: . Logs)
206275 pacModeItem? . title = String ( localized: . PacMode)
207276 globalModeItem? . title = String ( localized: . GlobalMode)
208277 manualModeItem? . title = String ( localized: . ManualMode)
@@ -238,31 +307,33 @@ final class AppMenuManager: NSObject {
238307 viewErrorLogItem = NSMenuItem ( title: String ( localized: . ViewErrorLog) , action: #selector( openErrorLogs) , keyEquivalent: " " )
239308 viewLogFilesItem = NSMenuItem ( title: String ( localized: . ViewLogFiles) , action: #selector( openLogFiles) , keyEquivalent: " " )
240309 clearLogsItem = NSMenuItem ( title: String ( localized: . ClearAllLogs) , action: #selector( clearLogs) , keyEquivalent: " " )
310+ logsItem = getLogsItem ( )
241311 // 配置查看
242312 menu. addItem ( toggleCoreItem)
243313 menu. addItem ( NSMenuItem . separator ( ) )
244314 menu. addItem ( viewConfigItem)
245315 menu. addItem ( viewPacItem)
246- menu. addItem ( viewLogItem)
247- menu. addItem ( viewErrorLogItem)
248- menu. addItem ( viewLogFilesItem)
249- menu. addItem ( clearLogsItem)
250316 menu. addItem ( NSMenuItem . separator ( ) )
251317 // 模式切换
252318 pacModeItem = getRunModeItem ( mode: . pac, title: String ( localized: . PacMode) , keyEquivalent: " " )
253319 globalModeItem = getRunModeItem ( mode: . global, title: String ( localized: . GlobalMode) , keyEquivalent: " " )
254320 manualModeItem = getRunModeItem ( mode: . manual, title: String ( localized: . ManualMode) , keyEquivalent: " " )
255321 tunnelModeItem = getRunModeItem ( mode: . tun, title: String ( localized: . TunnelMode) , keyEquivalent: " " )
322+ menu. addItem ( pacModeItem)
256323 menu. addItem ( tunnelModeItem)
257324 menu. addItem ( globalModeItem)
258325 menu. addItem ( manualModeItem)
259- menu. addItem ( pacModeItem)
260326 menu. addItem ( NSMenuItem . separator ( ) )
261327 // 路由与服务器
262328 routingItem = getRoutingItem ( )
263329 serverItem = getServerItem ( )
264330 // 预先初始化一次
265- pingItem = NSMenuItem ( title: String ( localized: . LatencyTest) + " \( self . pingTip) " , action: #selector( pingSpeed) , keyEquivalent: " " )
331+ pingItem = NSMenuItem ( )
332+ pingItemView = PingMenuItemView ( title: String ( localized: . LatencyTest) ) { [ weak self] in
333+ self ? . showPingTestingState ( )
334+ self ? . pingSpeedTest ( )
335+ }
336+ pingItem. view = pingItemView
266337 diagnosticsItem = NSMenuItem ( title: String ( localized: . Diagnostics) , action: #selector( openDiagnostics) , keyEquivalent: " " )
267338 menu. addItem ( NSMenuItem . separator ( ) )
268339 menu. addItem ( routingItem)
@@ -295,6 +366,7 @@ final class AppMenuManager: NSObject {
295366 // 设置与帮助
296367 checkForUpdatesItem = NSMenuItem ( title: String ( localized: . CheckForUpdates) + " (V2rayU v \( appVersion) ) " , action: #selector( checkForUpdate) , keyEquivalent: " " )
297368 helpItem = NSMenuItem ( title: String ( localized: . Help) + " (Xray-core \( getCoreShortVersion ( ) ) ) " , action: #selector( goHelp) , keyEquivalent: " " )
369+ menu. addItem ( logsItem)
298370 menu. addItem ( checkForUpdatesItem)
299371 menu. addItem ( helpItem)
300372 menu. addItem ( NSMenuItem . separator ( ) )
@@ -307,9 +379,21 @@ final class AppMenuManager: NSObject {
307379 item. target = self
308380 }
309381
382+ menu. delegate = self
310383 statusItem. menu = menu
384+ statusMenu = menu
311385 self . inited = true
312386 }
387+
388+ private func setPingMenuTitle( tip: String ) {
389+ pingTip = tip
390+ guard let pingItem else { return }
391+ let suffix = tip. isEmpty ? " " : " \( tip) "
392+ let title = String ( localized: . LatencyTest) + suffix
393+ pingItem. title = title
394+ pingItemView? . updateTitle ( title)
395+ statusMenu? . itemChanged ( pingItem)
396+ }
313397
314398 func getCoreStatusItem( ) -> NSMenuItem {
315399 // 使用 SwiftUI 视图替换原始的 title-only 菜单项,CoreStatusItemView 会观察 AppState 自动刷新
@@ -323,6 +407,24 @@ final class AppMenuManager: NSObject {
323407 item. isEnabled = false
324408 return item
325409 }
410+
411+ func getLogsItem( ) -> NSMenuItem {
412+ viewLogItem. target = self
413+ viewErrorLogItem. target = self
414+ viewLogFilesItem. target = self
415+ clearLogsItem. target = self
416+
417+ logsSubMenu = NSMenu ( )
418+ logsSubMenu. addItem ( viewLogItem)
419+ logsSubMenu. addItem ( viewErrorLogItem)
420+ logsSubMenu. addItem ( viewLogFilesItem)
421+ logsSubMenu. addItem ( NSMenuItem . separator ( ) )
422+ logsSubMenu. addItem ( clearLogsItem)
423+
424+ let item = NSMenuItem ( title: String ( localized: . Logs) , action: nil , keyEquivalent: " " )
425+ item. submenu = logsSubMenu
426+ return item
427+ }
326428
327429 func getRoutingItem( ) -> NSMenuItem {
328430 // 获取子菜单
@@ -482,6 +584,16 @@ final class AppMenuManager: NSObject {
482584 }
483585 }
484586
587+ func showPingTestingState( ) {
588+ setPingMenuTitle ( tip: " - " + String( localized: . Testing) + " ... " )
589+ }
590+
591+ func menuNeedsUpdate( _ menu: NSMenu ) {
592+ if menu === statusMenu {
593+ setPingMenuTitle ( tip: pingTip)
594+ }
595+ }
596+
485597 func importFromPasteboard( ) {
486598 if let uri = NSPasteboard . general. string ( forType: . string) , uri. count > 0 {
487599 importUri ( url: uri)
@@ -644,11 +756,6 @@ final class AppMenuManager: NSObject {
644756 @objc private func ImportFromPasteboard( _ sender: NSMenuItem ) {
645757 importFromPasteboard ( )
646758 }
647-
648- @objc private func pingSpeed( _ sender: NSMenuItem ) {
649- pingSpeedTest ( )
650- }
651-
652759 @objc private func viewConfig( _ sender: Any ) {
653760 openConfigFile ( )
654761 }
0 commit comments