diff --git a/.gitignore b/.gitignore index 66b5ac5..7017a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .DS_Store NeTool.xcodeproj/project.xcworkspace/xcuserdata/ - +NeTool.xcodeproj/xcuserdata/ diff --git a/NeTool.xcodeproj/project.pbxproj b/NeTool.xcodeproj/project.pbxproj index 19916c5..bf8bd03 100644 --- a/NeTool.xcodeproj/project.pbxproj +++ b/NeTool.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 7A431F392508E1D20050327A /* SpeedInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A431F382508E1D20050327A /* SpeedInfoView.swift */; }; + 7A431F3B2509BDFA0050327A /* SpeedInfoView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7A431F3A2509BDFA0050327A /* SpeedInfoView.xib */; }; 7A6E0090214CBD690020ED6D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6E008F214CBD690020ED6D /* AppDelegate.swift */; }; 7A6E0092214CBD690020ED6D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6E0091214CBD690020ED6D /* ViewController.swift */; }; 7A6E0094214CBD6A0020ED6D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7A6E0093214CBD6A0020ED6D /* Assets.xcassets */; }; @@ -35,6 +37,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 7A431F382508E1D20050327A /* SpeedInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeedInfoView.swift; sourceTree = ""; }; + 7A431F3A2509BDFA0050327A /* SpeedInfoView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SpeedInfoView.xib; sourceTree = ""; }; 7A6E008C214CBD690020ED6D /* NeTool.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NeTool.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7A6E008F214CBD690020ED6D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7A6E0091214CBD690020ED6D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; @@ -108,6 +112,8 @@ 7A6E0099214CBD6A0020ED6D /* NeTool.entitlements */, 7A6E00BB214CBD9B0020ED6D /* StatusBarView.swift */, 7A6E00BD214CDDA30020ED6D /* NetSpeedMonitor.swift */, + 7A431F382508E1D20050327A /* SpeedInfoView.swift */, + 7A431F3A2509BDFA0050327A /* SpeedInfoView.xib */, ); path = NeTool; sourceTree = ""; @@ -240,6 +246,7 @@ buildActionMask = 2147483647; files = ( 7A6E0094214CBD6A0020ED6D /* Assets.xcassets in Resources */, + 7A431F3B2509BDFA0050327A /* SpeedInfoView.xib in Resources */, 7A6E0097214CBD6A0020ED6D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -267,6 +274,7 @@ files = ( 7A6E00BC214CBD9B0020ED6D /* StatusBarView.swift in Sources */, 7A6E0092214CBD690020ED6D /* ViewController.swift in Sources */, + 7A431F392508E1D20050327A /* SpeedInfoView.swift in Sources */, 7A6E0090214CBD690020ED6D /* AppDelegate.swift in Sources */, 7A6E00BE214CDDA30020ED6D /* NetSpeedMonitor.swift in Sources */, ); @@ -434,12 +442,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2; INFOPLIST_FILE = NeTool/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.12; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = MicroStrategy.NeTool; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -452,12 +462,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 2; INFOPLIST_FILE = NeTool/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.12; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = MicroStrategy.NeTool; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/NeTool/AppDelegate.swift b/NeTool/AppDelegate.swift index 12f4db4..be16664 100644 --- a/NeTool/AppDelegate.swift +++ b/NeTool/AppDelegate.swift @@ -11,25 +11,54 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { - let view: StatusBarView + let speedMonitor: NetSpeedMonitor override init() { let statusItem = NSStatusBar.system.statusItem(withLength: 72) let menu = NSMenu() + // the menu item to show apps with top net speed (sum of upload and download) + let menuItem = NSMenuItem() + menuItem.view = SpeedInfoView() + menu.addItem(menuItem) + + // the menu item to quit app. menu.addItem(withTitle: "Quit NeTool", action: #selector(menuItemQuitClick), keyEquivalent: "q") - view = StatusBarView(statusItem: statusItem, menu: menu) - statusItem.view = view + // the view for menuBar icon + let menuBarIconView = StatusBarView(statusItem: statusItem, menu: menu) + statusItem.view = menuBarIconView + + // logic class to monitor net speed. + speedMonitor = NetSpeedMonitor(statusBarView: menuBarIconView, speedInfoView: menuItem.view as! SpeedInfoView) + menuBarIconView.speedMonitor = speedMonitor } func applicationDidFinishLaunching(_ aNotification: Notification) { - // Insert code here to initialize your application - NetSpeedMonitor(statusBarView: view).startMonitor() + // + speedMonitor.start() + + // observer event of system sleep and wake. + // we would pause monitoring on sleep, and resume monitoring on wake. + NSWorkspace.shared.notificationCenter.addObserver( + self, selector: #selector(onWake(notification:)), + name: NSWorkspace.didWakeNotification, object: nil) + + NSWorkspace.shared.notificationCenter.addObserver( + self, selector: #selector(onSleep(notification:)), + name: NSWorkspace.willSleepNotification, object: nil) } func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down your application + + } + + @objc func onSleep(notification: NSNotification) { + speedMonitor.stop() + } + + @objc func onWake(notification: NSNotification) { + speedMonitor.start() } } diff --git a/NeTool/Info.plist b/NeTool/Info.plist index 7684afc..0623662 100644 --- a/NeTool/Info.plist +++ b/NeTool/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/NeTool/NetSpeedMonitor.swift b/NeTool/NetSpeedMonitor.swift index 2b8735c..fe5d835 100644 --- a/NeTool/NetSpeedMonitor.swift +++ b/NeTool/NetSpeedMonitor.swift @@ -9,109 +9,404 @@ import Foundation open class NetSpeedMonitor { - static let interval: Double = 1.6 + static let interval: Int = 1400 static let KB: Double = 1024 static let MB: Double = KB * 1024 static let GB: Double = MB * 1024 static let TB: Double = GB * 1024 + static let TOP_ITEM_COUNT = 5; - var preBytesIn: Double = -1 - var preBytesOut: Double = -1 + // sum of upload bytes by all apps in last sample data. + var upBytesOfLast = 0 + var downBytesOfLast = 0 + + // sum of upload bytes by all apps in current sample data. + var upBytesOfCur = 0 + var downBytesOfCur = 0 + + // process ids of apps appeared in last sample data. + var pidsOfLastOutput = Array() + var pidsOfCurOutput = Array() + + // stores info of bytes and speed of multiple apps. + var pbArray = Array() - var lastIn = 0 - var lastOut = 0 - var curIn = 0 - var curOut = 0 + // the following 2 are for using "-l 0" argument of nettop command, which means the command + // would continuously output sample data, and we only execute this command once, and continuously + // consume the data. + // but currently we still use "-l 1" due to stability problem. + // the last line of last output, but the line is incomplete, hence we save and use it when next output is available. + var inCompleteLastLineOfLastOutput = "" + // the length of last header line. a header line starts with word "time", ends with word "bytes_out" + var lenOflastHeaderLine = 0 + + // timer to periodiclly execute nettop command. + var timer: DispatchSourceTimer? = nil let statusBarView: StatusBarView + let speedInfoView: SpeedInfoView - init(statusBarView: StatusBarView) { + init(statusBarView: StatusBarView, speedInfoView: SpeedInfoView) { self.statusBarView = statusBarView + self.speedInfoView = speedInfoView } - func startMonitor() { - Thread(target: self, selector: #selector(startTimer), object: nil).start() - } - - @objc func startTimer() { - Timer.scheduledTimer(timeInterval: NetSpeedMonitor.interval, target: self, selector: #selector(sampleBytes), userInfo: nil, repeats: true) - RunLoop.current.run() + func start() { + if (timer != nil) { + timer?.resume() + return + } + + timer = DispatchSource.makeTimerSource(flags: [], queue: DispatchQueue.global()) + timer?.schedule( + deadline: .now(), + repeating: DispatchTimeInterval.milliseconds(NetSpeedMonitor.interval), + leeway: DispatchTimeInterval.milliseconds(NetSpeedMonitor.interval)) + timer?.setEventHandler { + // Create a Task instance + let task = Process() + task.launchPath = "/usr/bin/nettop" + // -x to get value with Byte as unit, rather than MB, GB etc. + // -t wifi -t wired to choose type of network interface we want. + // -J to pick columns of output we want. + // -l 1 to get only one sample data. + task.arguments = [ + "-x", "-t", "wifi", "-t", "wired", "-J","time,bytes_in,bytes_out", "-P", "-l", "1"] + let pipe = Pipe() + task.standardOutput = pipe + // Launch the task + task.launch() + + // Get the data + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if #available(OSX 10.15, *) { + do { + try pipe.fileHandleForReading.close() + } catch { } + } + let output = String(data: data, encoding: String.Encoding.utf8) ?? "" + self.handleOutput(fetchedData: output) + } + timer?.resume() } - @objc func sampleBytes() { - // Create a Task instance - let task = Process() + func stop() { + timer?.suspend() - // Set the task parameters - task.launchPath = "/usr/bin/nettop" - task.arguments = ["-x", "-t", "wifi", "-t", "wired", "-k", "interface,state,rx_dupe,rx_ooo,re-tx,rtt_avg,rcvsize,tx_win,tc_class,tc_mgt,cc_algo,P,C,R,W", "-P", "-l", "1"] + // reset the following variables. - // Create a Pipe and make the task - // put all the output there - let pipe = Pipe() - task.standardOutput = pipe + upBytesOfLast = 0 + downBytesOfLast = 0 - // Launch the task - task.launch() + upBytesOfCur = 0 + downBytesOfCur = 0 - // Get the data - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: String.Encoding.utf8) ?? "" - handleNetWorkData(string: output) + inCompleteLastLineOfLastOutput = "" + lenOflastHeaderLine = 0 + + pidsOfLastOutput.removeAll() + pidsOfCurOutput.removeAll() + + pbArray.removeAll() + + self.statusBarView.updateData(up: StatusBarView.INITIAL_RATE_TEXT, down: StatusBarView.INITIAL_RATE_TEXT) } - func handleNetWorkData(string: String) { + // this methods contains logic to consider unexpected output of nettop with "-l 0" argument, + // see comment of inCompleteLastLineOfLastOutput. + // many code are unnecessary for nettop with "-l 1" argument, i.e. current situation. + func handleOutput(fetchedData: String) { + + // the original nettop with previous arguments would produce output intervally and neatly. + // i.e. if you set "-s 1" argument for the command, the output would be produced per second, + // and each output is just sample of that second. + // however, each batch of output text we fetched from the data observer might be not one complete sample output. - let lines = string.split(separator: "\n") - curIn = 0 - curOut = 0 - for line in lines { - if line.count == 0 || line.starts(with: "time") { + var validLines = Array() + // first, split the output into several lines. the 1st line and last line might be incomplete. + // so we insert the inCompleteLastLineOfLastOutput in front of it, to mke sure 1st line is complete. + let lines = (inCompleteLastLineOfLastOutput + fetchedData).split(separator: "\n") + inCompleteLastLineOfLastOutput = "" // clear after usage. + + for i in 0...(lines.count - 1) { + let line = lines[i] + if line.count == 0 { // skip empty lines. continue } - let lineParts = line.split(separator:" ") - let linePartsLen = lineParts.count - let inBytes:Int = Int(lineParts[linePartsLen-2])! - let outBytes:Int = Int(lineParts[linePartsLen-1])! - curIn += inBytes - curOut += outBytes - } - if lastIn > 0 && lastOut > 0 { - let upStr = NetSpeedMonitor.getSpeedString(bytesPerSecond: ((Double)(curOut-lastOut) / NetSpeedMonitor.interval)) - let downStr = NetSpeedMonitor.getSpeedString(bytesPerSecond: ((Double)(curIn-lastIn) / NetSpeedMonitor.interval)) + + if line.starts(with: "time") { + // if this is a header line and is complete, update the lenOflastHeaderLine + if String(line).substring(fromIndex: line.count - 3) == "out" { + validLines.append(String(line)) + lenOflastHeaderLine = line.count + } else { + if (i == lines.count - 1) { + inCompleteLastLineOfLastOutput = String(line) + } else { + // unexpected output + } + } + } else { + // condition to check whether the line is complete + if line.count == lenOflastHeaderLine { + validLines.append(String(line)) + } else { + if (i == lines.count - 1) { + inCompleteLastLineOfLastOutput = String(line) + } else { + // unexpected output + } + } + } + } + + // clear + upBytesOfCur = 0; + downBytesOfCur = 0; + pidsOfCurOutput.removeAll() + + if pbArray.count > 0 { + // iterate for each pbArray element. + for i in 0...(pbArray.count - 1) { + pbArray[i].upBytes1 = pbArray[i].upBytes2 + pbArray[i].upBytes2 = 0 + pbArray[i].downBytes1 = pbArray[i].downBytes2 + pbArray[i].downBytes2 = 0 + } + } + + // now all lines inside validLines are complete, including the header line. handle them. + for line in validLines { + handleOneLineOutput(line: line) + } + // update the menubar icon. + if (upBytesOfLast > 0 && downBytesOfLast > 0) { + let upStr = NetSpeedMonitor.getSpeedString(bytes1: upBytesOfLast, bytes2: upBytesOfCur) + let downStr = NetSpeedMonitor.getSpeedString(bytes1: downBytesOfLast, bytes2: downBytesOfCur) self.statusBarView.updateData(up: upStr, down: downStr) } - lastIn = curIn - lastOut = curOut + // iterate. + upBytesOfLast = upBytesOfCur + downBytesOfLast = downBytesOfCur + + // sort by sum of up & down bytes. + pbArray.sort(by: {pb1, pb2 in + (pb1.downBytes2 - pb1.downBytes1 + pb1.upBytes2 - pb1.upBytes1) > + (pb2.downBytes2 - pb2.downBytes1 + pb2.upBytes2 - pb2.upBytes1) + }) + + // check if pids of current output mostly appear in last output. + // if not so, restart the monitoring. + if pidsOfLastOutput.count > 0 { + var appearedCount = 0 + for pid in pidsOfCurOutput { + if pidsOfLastOutput.contains(pid) { + appearedCount += 1 + } + } + // more than 3 process not appear in last sample + if pidsOfCurOutput.count - appearedCount > 3 { + // unexpected output + } + } + + // iterate + pidsOfLastOutput.removeAll() + for pid in pidsOfCurOutput { + pidsOfLastOutput.append(pid) + } + + // if dropdown menu is expanded, calculate TOP_ITEM_COUNT processes with top download speed. + if (statusBarView.isMenuShown()) { + updateTopSpeedItems() + } } - - static func getSpeedString(bytesPerSecond: Double) -> String { - if bytesPerSecond < KB/100 { - return "0 B/S" + + // header line is like "time bytes_in bytes_out" + // other lines are like "16:59:11.290649 UserEventAgent.104 313206 431240", which contains time, process name, process id, bytes downloaded and bytes uploaded. + func handleOneLineOutput(line: String) { + if line.starts(with: "time") { // skip header line. + return + } + + let lineParts = line.split(separator: " ") + let downBytes:Int = Int(lineParts[lineParts.count - 2])! + let upBytes:Int = Int(lineParts[lineParts.count - 1])! + upBytesOfCur += upBytes + downBytesOfCur += downBytes + + // process name and process id, like "Google Chrome H.1567", we need to get the pid. + let pNameAndPid = String(lineParts[lineParts.count - 3]) + let pid = String(pNameAndPid[pNameAndPid.index(after: pNameAndPid.lastIndex(of: ".")!)...]) + let pbIdx = getPbIndexByPid(pid: pid) + // check whether there is already a ProcessBytes object for this process. + if pbIdx == nil { + // no, then create a new one. + let pb = ProcessBytes(pid: pid, upBytes1: 0, upBytes2: upBytes, downBytes1: 0, downBytes2: downBytes) + pbArray.append(pb) + } else { + pbArray[pbIdx!].upBytes2 = upBytes + pbArray[pbIdx!].downBytes2 = downBytes } + // store the process id + pidsOfCurOutput.append(pid) + } + + // bytes1: accumulated bytes of last sample + // bytes2: accumulated bytes of current sample + static func getSpeedString(bytes1: Int, bytes2: Int) -> String { + let bytesPerSecond = (bytes2 - bytes1) * 1000 / interval var result:Double var unit: String - if bytesPerSecond < MB{ - result = bytesPerSecond / KB - unit = " K/S" - } else if bytesPerSecond < GB { - result = bytesPerSecond / MB - unit = " M/S" - } else if bytesPerSecond < TB { - result = bytesPerSecond / GB - unit = " G/S" + if (bytesPerSecond < 10) { + return "0 B/S" + } else if bytesPerSecond < 1000 { + return String(bytesPerSecond) + " B/S" + } + let bytesPerSecondDouble = (Double)(bytesPerSecond) + if bytesPerSecondDouble < 1000 * KB { + result = bytesPerSecondDouble / KB + unit = "K/S" + } else if bytesPerSecondDouble < 1000 * MB { + result = bytesPerSecondDouble / MB + unit = "M/S" + } else if bytesPerSecondDouble < 1000 * GB { + result = bytesPerSecondDouble / GB + unit = "G/S" } else { - return "MAX /S" + return "MAX /S" } if result < 100 { - return String(format: "%0.2f ", result) + unit - } else if result < 999 { - return String(format: "%0.1f ", result) + unit + // keep at most 2 decimals. + return String((result * 100).rounded() / 100) + " " + unit } else { - return String(format: "%0.0f ", result) + unit + // keep at most 1 decimal. + return String((result * 10).rounded() / 10) + " " + unit + } + } + + func getPbIndexByPid(pid: String) -> Int? { + if (pbArray.count > 0) { + for i in 0...(pbArray.count - 1) { + if pbArray[i].pid == pid { + return i + } + } + } + return nil + } + + // get an array of SpeedInfo objects which represent apps with top net speed + func getTopSpeedInfo() -> Array? { + if (pbArray.count < 5) { + return nil } + // use ps command to get all process path of the top 5 processes. + let pidsTemplate = "%@,%@,%@,%@,%@" + let pids = String(format: pidsTemplate, pbArray[0].pid, pbArray[1].pid, pbArray[2].pid, pbArray[3].pid, pbArray[4].pid) + let task = Process() + task.launchPath = "/bin/ps" + task.arguments = ["-p", pids] + let pipe = Pipe() + task.standardOutput = pipe + task.launch() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: String.Encoding.utf8) ?? "" + let lines = output.split(separator: "\n") + var result = Array() + + let pathStartIdx = String(lines[0]).indexOf(str: "CMD") + for i in 0...(min(NetSpeedMonitor.TOP_ITEM_COUNT, pbArray.count) - 1) { + // find the line which contains pid of pbArray[i]. + var line: String? = nil + for ln in lines { + if ln.trimmingCharacters(in: .whitespacesAndNewlines).starts(with: pbArray[i].pid) { + line = String(ln) + break + } + } + if line != nil { + // for XXX.app case, only take XXX.app as path. + let idx = line!.range(of: ".app/Contents/")?.lowerBound + var path: String + if idx == nil { + let wholeCmd = line!.substring(fromIndex: pathStartIdx) + // only take characters before the first space, because characters after + // the space might be arguments of the process. + let spaceIdx = wholeCmd.indexOf(str: " ") + if spaceIdx > 0 { + path = wholeCmd.substring(toIndex: spaceIdx) + } else { + path = wholeCmd + } + } else { + let trimedDotApp = String(line![.. String { + return self[i ..< i + 1] + } + + func substring(fromIndex: Int) -> String { + return self[min(fromIndex, length) ..< length] + } + + func substring(toIndex: Int) -> String { + return self[0 ..< max(0, toIndex)] + } + + subscript (r: Range) -> String { + let range = Range(uncheckedBounds: (lower: max(0, min(length, r.lowerBound)), + upper: min(length, max(0, r.upperBound)))) + let start = index(startIndex, offsetBy: range.lowerBound) + let end = index(start, offsetBy: range.upperBound - range.lowerBound) + return String(self[start ..< end]) + } + + func indexOf(str: String) -> Int { + return range(of: str)?.lowerBound.utf16Offset(in: self) ?? -1 } } diff --git a/NeTool/SpeedInfoView.swift b/NeTool/SpeedInfoView.swift new file mode 100644 index 0000000..6d08a40 --- /dev/null +++ b/NeTool/SpeedInfoView.swift @@ -0,0 +1,116 @@ +// +// SpeedInfoView.swift +// NeTool +// +// Created by Liu, Tao (Toni) on 9/9/20. +// Copyright © 2020 Liu, Tao (Toni). All rights reserved. +// + +import AppKit + +class SpeedInfoView: NSControl { + + @IBOutlet weak var hintHabel: NSTextField! + + @IBOutlet weak var pathLabel0: NSTextField! + @IBOutlet weak var pathLabel1: NSTextField! + @IBOutlet weak var pathLabel2: NSTextField! + @IBOutlet weak var pathLabel3: NSTextField! + + @IBOutlet weak var speedLabel0: NSTextField! + @IBOutlet weak var speedLabel1: NSTextField! + @IBOutlet weak var speedLabel2: NSTextField! + @IBOutlet weak var speedLabel3: NSTextField! + + var pathLabelArr: Array = [] + var speedLabelArr: Array = [] + + init() { + + super.init(frame: NSMakeRect(0, 0, 300, 148)) + + // load from xib file. + let newNib = NSNib(nibNamed: "SpeedInfoView", bundle: Bundle(for: type(of: self))) + newNib!.instantiate(withOwner: self, topLevelObjects: nil) + + pathLabelArr = [pathLabel0, pathLabel1, pathLabel2, pathLabel3] + speedLabelArr = [speedLabel0, speedLabel1, speedLabel2, speedLabel3] + + addSubview(hintHabel) + hintHabel.stringValue = "Click to copy path" + for label in pathLabelArr { + addSubview(label) + label.stringValue = "- - -" + } + for label in speedLabelArr { + addSubview(label) + label.stringValue = "- - B/S ▲\n- - B/S ▼" + } + + setNeedsDisplay() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func draw(_ dirtyRect: NSRect) { + // draw two divider lines. + NSColor.gray.set() + let figure = NSBezierPath() + figure.lineWidth = 1 + figure.move(to: NSMakePoint(18, 6)) + figure.line(to: NSMakePoint(290, 6)) + figure.stroke() + + figure.move(to: NSMakePoint(18, 126)) + figure.line(to: NSMakePoint(290, 126)) + figure.stroke() + } + + override func mouseDown(with event: NSEvent) { + + // event.locationInWindow is relative to window, convert that to be relative this view. + let clickLocation = convert(event.locationInWindow, from: nil) + + for label in pathLabelArr { + if (label.frame.contains(clickLocation)) { + // copy path to clipborad, then users could use it. + copyToClipBoard(textToCopy: label.stringValue) + } + } + } + + func updateTopSpeedItems(infoArr: Array?) { + DispatchQueue.main.async { + if (infoArr != nil) { + let count = min(infoArr!.count, self.pathLabelArr.count) + for i in 0...(count - 1) { + self.pathLabelArr[i].stringValue = infoArr![i].path + self.speedLabelArr[i].stringValue = infoArr![i].upSpeed + " ▲\n" + infoArr![i].downSpeed + " ▼" + //self.speedLabelArr[i].stringValue = "440.0 K/S" + " ▲\n" + "1000.8 M/S" + " ▼" + } + } + } + } + + private func copyToClipBoard(textToCopy: String) { + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.setString(textToCopy, forType: .string) + + setHintText(new: "Path copied", duration: 2, recoverTo: "Click to copy path") + } + + // new: new hint text to show + // duration: time seconds the new hint would last. + // recoverTo: hint text to shown after duration. + private func setHintText(new: String, duration: Int, recoverTo: String) { + hintHabel.stringValue = new + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(duration), execute: { + self.hintHabel.stringValue = recoverTo + }) + } +} + diff --git a/NeTool/SpeedInfoView.xib b/NeTool/SpeedInfoView.xib new file mode 100644 index 0000000..b5dfc1b --- /dev/null +++ b/NeTool/SpeedInfoView.xib @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NeTool/StatusBarView.swift b/NeTool/StatusBarView.swift index 76bc5bc..e1738d3 100644 --- a/NeTool/StatusBarView.swift +++ b/NeTool/StatusBarView.swift @@ -10,17 +10,23 @@ import AppKit import Foundation open class StatusBarView: NSControl { + public static let INITIAL_RATE_TEXT = "- - B/S" var statusItem: NSStatusItem + // true if users clicked menubar icon, and dropdown menu is shown. var clicked: Bool = false + // dark menu bar & dock style of OS before Mojave. var darkMenuBar: Bool = false - var upRate: String = "- - B/S" - var downRate: String = "- - B/S" + var upRate: String = INITIAL_RATE_TEXT + var downRate: String = INITIAL_RATE_TEXT + + public var speedMonitor: NetSpeedMonitor? init(statusItem: NSStatusItem, menu: NSMenu?) { self.statusItem = statusItem - super.init(frame: NSMakeRect(0, 0, statusItem.length, 30)) + super.init(frame: NSMakeRect(0, 0, statusItem.length, NSStatusItem.squareLength)) self.menu = menu + menu?.delegate = self darkMenuBar = isDarkMode() @@ -36,6 +42,8 @@ open class StatusBarView: NSControl { // draw the background statusItem.drawStatusBarBackground(in: dirtyRect, withHighlight: clicked) + // draw up speed string and down speed string. + let textColor = (clicked || darkMenuBar) ? NSColor.white : NSColor.black let textAttr = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 9), NSAttributedString.Key.foregroundColor: textColor] @@ -73,20 +81,33 @@ open class StatusBarView: NSControl { self.setNeedsDisplay() }) } + + func isMenuShown() -> Bool { + return self.clicked + } } //action extension StatusBarView: NSMenuDelegate{ open override func mouseDown(with theEvent: NSEvent) { + statusItem.popUpMenu(menu!) } public func menuWillOpen(_ menu: NSMenu) { setNeedsDisplay() + self.clicked = true + + // fetch the top speed info of the last sample, hence users + // can see the results once menu is shown, rather than waiting for results of next sapmple. + DispatchQueue.global().async { + self.speedMonitor?.updateTopSpeedItems() + } } public func menuDidClose(_ menu: NSMenu) { setNeedsDisplay() + self.clicked = false } }