diff --git a/shbar2.xcodeproj/project.pbxproj b/shbar2.xcodeproj/project.pbxproj index 7344b04..a4136eb 100644 --- a/shbar2.xcodeproj/project.pbxproj +++ b/shbar2.xcodeproj/project.pbxproj @@ -95,11 +95,11 @@ 9B45A9A3221B7B4000305441 /* JobStatus.swift */, 9B45A9A1221B7B2600305441 /* ItemConfigMode.swift */, 9B45A99F221B7B0B00305441 /* Script.swift */, + 9B4912D1221CCAD2006E6C73 /* LabelProtocol.swift */, 9BDEE78522163235006BA354 /* Assets.xcassets */, 9BDEE78722163235006BA354 /* Main.storyboard */, 9BDEE78A22163235006BA354 /* Info.plist */, 9BDEE78B22163235006BA354 /* shbar2.entitlements */, - 9B4912D1221CCAD2006E6C73 /* LabelProtocol.swift */, ); path = shbar2; sourceTree = ""; diff --git a/shbar2/AppDelegate.swift b/shbar2/AppDelegate.swift index 94302ed..20b5006 100644 --- a/shbar2/AppDelegate.swift +++ b/shbar2/AppDelegate.swift @@ -8,10 +8,31 @@ import Cocoa import Foundation +import UserNotifications @NSApplicationMain -class AppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + /// Store ids / itemconfig mappings. + /// Used to translate jobs across notifications. + var responderItems : [String:ItemConfig] = [:] + + + /// Register a job with the responder dict. + /// This allows for finding later with the returned ID. + func registerProcessNotificationID(job: ItemConfig) -> String { + let str = UUID.init().uuidString + self.responderItems[str] = job + return str + } + + + /// Get a process via it's ID + func getProcessByNotificationID(id: String) -> ItemConfig? { + return responderItems[id] + } + + // Menu items to display. Set to default config with help. var menuItems : [ItemConfig] = [ ItemConfig( title: "IP Address", @@ -41,6 +62,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { "PATH": "/usr/bin:/usr/local/bin:/sbin:/bin" ]) ), + ItemConfig( + title: "Show Config Folder", + actionScript: Script( + bin: "/bin/sh", + args: ["-c", "open \(AppDelegate.userHomeDirectoryPath)/.config/shbar/"], + env: [ + "PATH": "/usr/bin:/usr/local/bin:/sbin:/bin" + ]) + ), ItemConfig( mode: .ApplicationQuit, title: "Quit", @@ -51,6 +81,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var statusItems : [NSStatusItem] = [] + /// Get a link to the user's home path static var userHomeDirectoryPath : String { let pw = getpwuid(getuid()) let home = pw?.pointee.pw_dir @@ -59,25 +90,40 @@ class AppDelegate: NSObject, NSApplicationDelegate { return homePath } + /// Handler for app launch. func applicationDidFinishLaunching(_ aNotification: Notification) { let manager = FileManager.default + + let restartAction = UNNotificationAction(identifier: "restart", title: "Restart", options: []) + let logsAction = UNNotificationAction(identifier: "logs", title: "View Logs", options: []) + + let jobAlert = UNNotificationCategory(identifier: "jobAlert", actions: [restartAction, logsAction], intentIdentifiers: [], options: []) + UNUserNotificationCenter.current().setNotificationCategories([jobAlert]) + + + // Create config directory do { try manager.createDirectory(atPath: "\(AppDelegate.userHomeDirectoryPath)/.config/shbar", withIntermediateDirectories: true) } catch let error { print("Error creating config directory: \(error)") } + // Create log directory do { try manager.createDirectory(atPath: "\(AppDelegate.userHomeDirectoryPath)/Library/Logs/shbar", withIntermediateDirectories: false) } catch let error { print("Error creating log directory: \(error)") - } + } + // Attempt to decode the JSON config file. let json = try? Data(contentsOf: URL(fileURLWithPath: "\(AppDelegate.userHomeDirectoryPath)/.config/shbar/shbar.json")) + + // If load works, try to decode into an itemconfig. if let json = json { let decoder = JSONDecoder() let decodedItems = try? decoder.decode([ItemConfig].self, from: json) + // Assign the new items into the global item list. if let decodedItems = decodedItems { print("Loaded from File!") menuItems = decodedItems @@ -86,12 +132,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { print("No config file present!") } + // Next, pretty-print and format the current config. let jsonEncoder = JSONEncoder() jsonEncoder.outputFormatting = .prettyPrinted + // Print the config out. let data = try? jsonEncoder.encode(menuItems) print(String(data: data!, encoding: .utf8)!) + + // Initialize the menu items. for item in menuItems { let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) // Set main title @@ -99,11 +149,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { item.menuItem = statusItem.button! item.initializeTitle() + // Set up action to dispatch a script. if item.actionScript != nil { statusItem.button!.action = #selector(ItemConfig.dispatchAction) statusItem.button!.target = item } + // TODO: why is this incorrectly sized? // if item.menuItem?.title == "SHBAR" { // item.menuItem?.title = "" // let image = NSImage(named: "Image-1") @@ -132,18 +184,56 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Terminate jobs for item in menuItems { + item.currentJob?.interrupt() item.currentJob?.terminate() } + + print("termination complete.") } @objc func terminateMenuBarApp(_ sender: NSMenuItem?) { self.terminateRemainingJobs() NSApplication.shared.terminate(self) } - - func handler(sig: Int32) -> Void { - self.terminateMenuBarApp(nil) + + /// Allow in-app notifications (for when menu is focused) + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + + completionHandler([.alert, .sound, .badge]) } + + /// Enable message handling. + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: + @escaping () -> Void) { + // Get the meeting ID from the original notification. + let userInfo = response.notification.request.content.userInfo + + // Try to get Job notification ID + if let id = userInfo["job"] as? String { + // Try to find associated job. + if let job = self.getProcessByNotificationID(id: id) { + + // Parse actions + if response.notification.request.content.categoryIdentifier == "jobAlert" { + if response.actionIdentifier == "logs" { + job.showJobConsole() + } + + if response.actionIdentifier == "restart" || response.actionIdentifier == "start"{ + job.startJob() + } + } + } else { + print("Can't find Job.") + } + } else { + print("No job ID!") + } + + completionHandler() + } } diff --git a/shbar2/Info.plist b/shbar2/Info.plist index ef27ffa..ea5bcf5 100644 --- a/shbar2/Info.plist +++ b/shbar2/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.0.2 CFBundleVersion - 10 + 12 LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion @@ -32,5 +32,7 @@ Main NSPrincipalClass NSApplication + NSUserNotificationAlertStyle + alert diff --git a/shbar2/ItemConfig.swift b/shbar2/ItemConfig.swift index 69c2b0d..7faeaac 100644 --- a/shbar2/ItemConfig.swift +++ b/shbar2/ItemConfig.swift @@ -8,33 +8,82 @@ import Cocoa import Foundation +import UserNotifications class ItemConfig : Codable { + /// The mode for the menu item. var mode: ItemConfigMode? = .RefreshingItem + + /// Display title var title: String? + + /// Title script to run. var titleScript: Script? + + /// Refresh interval for the title. var titleRefreshInterval: TimeInterval? + + /// Script to un on click. var actionScript: Script? + + /// Shortcut key for the menu item var shortcutKey: String? + + /// For Job-Type Items, the job script. var jobScript: Script? + + /// Should we auto-reload the job when it fails? var reloadJob: Bool? + + /// Should we auto-start the job on startup. var autostartJob: Bool? + + /// Attachment to the menu label for updating text. + /// This is a commmon protocol adapter for different types of system labels. var menuItem: LabelProtocol? + + /// Timer scheduling refresh events. var refreshTimer: Timer? + + /// Job status menu item - for jobs with status, this is a label to show "running"/"suspended"/etc.. states. var jobStatusItem: NSMenuItem? + + /// The exit status from the job. var jobExitStatus: Int32? + + /// The currently runnin job process var currentJob: Process? + + /// Is the job paused? var isPaused : Bool = false + + /// Is the action a console show? var actionShowsConsole: Bool? = false + + /// Children of this item. var children: [ItemConfig]? = [] - + + /// Menu item for the start job button var startMenuItem: NSMenuItem? + + /// Menu item for the stop job button var stopMenuItem: NSMenuItem? + + /// Menu item for the restart job button var restartMenuItem: NSMenuItem? + + /// Menu item for the suspend job button var suspendMenuItem: NSMenuItem? + + /// Menu item for the resume job button var resumeMenuItem: NSMenuItem? + + /// Menu item for the showing console of job. var consoleMenuItem: NSMenuItem? + var delegate: AppDelegate? + + /// Keys to encode / decode for direct-to-config (de)serialization private enum CodingKeys: String, CodingKey { case mode case children @@ -49,6 +98,8 @@ class ItemConfig : Codable { case actionShowsConsole } + /// Initializer for building in-code. + /// Primarily for demo ui. init( mode: ItemConfigMode? = .RefreshingItem, title: String? = nil, @@ -73,6 +124,8 @@ class ItemConfig : Codable { self.children = children } + /// Function for suspending the current job, if it's running. + /// This updates the status labels. @objc func suspendJob() { if let process = self.currentJob { process.suspend() @@ -83,7 +136,9 @@ class ItemConfig : Codable { } } } - + + /// Function for resuming the current job, if it's stopped. + /// This updates the status labels. @objc func resumeJob() { if let process = self.currentJob { process.resume() @@ -95,7 +150,11 @@ class ItemConfig : Codable { } } + /// Function for starting the current job, if it's not running. + /// Running jobs are first killed. + /// This updates the status labels. @objc func startJob() { + self.currentJob?.interrupt() self.currentJob?.terminate() if let script = self.jobScript { @@ -109,11 +168,45 @@ class ItemConfig : Codable { } }, completed: { status in + + // Register for notifications if we have a delegate. + if let delegate = self.delegate { + let id = delegate.registerProcessNotificationID(job: self) + + // Create content + let content = UNMutableNotificationContent() + content.title = "\(self.title ?? "Process")" + content.categoryIdentifier = "jobAlert" + + // Show info for auto-restarts. + if self.reloadJob == true { + content.body = "Auto-Restarted; Exited with code: \(status)" + } else { + content.body = "Exited with code: \(status)" + } + + content.sound = UNNotificationSound.default + content.userInfo = [ "job": id ] + // Trigger after 0.1s. + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) + + // Schedule request. + let request = UNNotificationRequest(identifier: "localNotification", content: content, trigger: trigger) + + // Set delegate and add. + UNUserNotificationCenter.current().delegate = self.delegate + UNUserNotificationCenter.current().add(request) { (error) in + print(error) + } + } + + // update title if let title = self.title { self.updateTitle(title: title) } + // Reload if needed. if let reloadJob = self.reloadJob, reloadJob { self.startJob() } @@ -121,6 +214,7 @@ class ItemConfig : Codable { } } + /// Stop a job. @objc func stopJob() { self.reloadJob = false if let job = self.currentJob { @@ -137,6 +231,7 @@ class ItemConfig : Codable { } } + /// Show console. @objc func showJobConsole() { if let jobScript = self.jobScript, let uuid = jobScript.uuid { DispatchQueue.global(qos: .background).async { @@ -150,6 +245,7 @@ class ItemConfig : Codable { } } + /// Update title / job status. func updateTitle(title: String) { if self.mode == .JobStatus { var color = NSColor.gray @@ -223,6 +319,7 @@ class ItemConfig : Codable { /// Generate the submenu for this item // If it has none, nil is returned. func createSubMenu(_ appDelegate: AppDelegate) -> NSMenu? { + self.delegate = appDelegate if let children = self.children, children.count > 0 { let subMenu = NSMenu() subMenu.autoenablesItems = true @@ -298,6 +395,7 @@ class ItemConfig : Codable { /// Create a menu item for this. func createMenuItem(_ appDelegate: AppDelegate) -> NSMenuItem { let menuItem = NSMenuItem() + self.delegate = appDelegate let subMenu = self.createSubMenu(appDelegate) diff --git a/shbar2/ItemConfigMode.swift b/shbar2/ItemConfigMode.swift index 1d8db98..3bcfc0f 100644 --- a/shbar2/ItemConfigMode.swift +++ b/shbar2/ItemConfigMode.swift @@ -9,7 +9,13 @@ import Foundation enum ItemConfigMode : String, Codable { + /// Refreshes contents with output of script. case RefreshingItem + + /// Displays job name / status icon. + /// Contains submenu of launch actions. case JobStatus + + /// Quit the app. case ApplicationQuit } diff --git a/shbar2/JobStatus.swift b/shbar2/JobStatus.swift index 347b034..c17b062 100644 --- a/shbar2/JobStatus.swift +++ b/shbar2/JobStatus.swift @@ -9,7 +9,12 @@ import Foundation enum JobStatus : String { + /// The job is suspended. case Stopped + + /// The job is exited. case Exited + + /// The job is running case Running } diff --git a/shbar2/LabelProtocol.swift b/shbar2/LabelProtocol.swift index ea1c145..47876fa 100644 --- a/shbar2/LabelProtocol.swift +++ b/shbar2/LabelProtocol.swift @@ -16,6 +16,7 @@ protocol LabelProtocol { var allowsNewlines : Bool { get } } +/// Add conformanse for NSMenuItem extension NSMenuItem : LabelProtocol { var allowsNewlines : Bool { return false } var attributedTitleString: NSAttributedString? { @@ -28,6 +29,7 @@ extension NSMenuItem : LabelProtocol { } } +/// Add conformanse for NSStatusBarButton extension NSStatusBarButton : LabelProtocol { var allowsNewlines : Bool { return false } var attributedTitleString: NSAttributedString? { diff --git a/shbar2/Script.swift b/shbar2/Script.swift index f1b8fbb..1d715b2 100644 --- a/shbar2/Script.swift +++ b/shbar2/Script.swift @@ -36,6 +36,7 @@ class Script : Codable { self.env = env } + /// Launch a job. func launchJob(launched: ((Process)->())? = nil, completed: ((Int32)->())? = nil) { DispatchQueue.global(qos: .background).async { [weak self] in guard let `self` = self else { return }