diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..24d99f9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +## Change log + +2025-01-19: v2.5.4 - Better handling of the health status window. + +2025-01-15: v2.5.3 - Actively update health status window if it is presented. + +2025-12-24: v2.5.1 - Log health status rates below 1.0. + +2025-12-21: v2.5.0 - Add basic hardware, OS, and jamfStatus app usage collection. Data is sent anonymously to [TelemetryDeck](https://telemetrydeck.com) to aid in the development of the app. View 'About...' to opt out of sending the data. Add ability to view health-status for Jamf Cloud hosted instances. Fix an issue with token renewal. + +2023-12-06: v2.4.1 - Minor updates to the alerts display. + +2023-11-11: v2.4.0 - Fix issue with notifications not being displayed. Add ability to use API client. + +2023-04-07: v2.3.6 - Update logging to prevent potential looping. + +2022-10-02: v2.3.2 - Rework authentication/token refresh. + +2022-06-12: v2.3.1 - Clean up notificatations not displaying properly. + +2021-10-15: v2.3.0 - Updated notifications display. diff --git a/README.md b/README.md index f569ce3..200966a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # jamfStatus +![GitHub release (latest by date)](https://img.shields.io/github/v/release/jamf/jamfStatus?display_name=tag) ![GitHub all releases](https://img.shields.io/github/downloads/jamf/jamfStatus/total) ![GitHub latest release](https://img.shields.io/github/downloads/jamf/jamfStatus/latest/total) + ![GitHub issues](https://img.shields.io/github/issues-raw/jamf/jamfStatus) ![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/jamf/jamfStatus) ![GitHub pull requests](https://img.shields.io/github/issues-pr-raw/jamf/jamfStatus) ![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed-raw/jamf/jamfStatus) + Download: [jamfStatus](https://github.com/jamf/jamfStatus/releases/latest/download/jamfStatus.zip) -The application submits basic hardware, OS, and jamfStatus application usage to [TelemetryDeck](https://telemetrydeck.com) by default. The data is sent anonymously and can be disabled by clicking 'Opt out of analytics' from the About window. +The application submits basic hardware, OS, and jamfStatus application usage to [TelemetryDeck](https://telemetrydeck.com) by default. The data is sent anonymously and can be disabled by clicking 'Opt out of analytics' from the 'About...' window. Keep an eye on the status of Jamf Cloud with jamfStatus. The app will place an icon in the menu bar to reflect the current cloud status. @@ -123,20 +126,3 @@ Thu Sep 17 20:27:30 Jamf Cloud: All systems go. * Major Update for Jamf Connect Now Available (Jamf Connect <latestVersion>) * Device Compliance Connection Interrupted * Conditional Access Connection Interrupted - - -## Change log - -2025-12-21: v2.5.0 - Add basic hardware, OS, and jamfStatus app usage collection. Data is sent anonymously to [TelemetryDeck](https:telemetrydeck.com) to aid in the development of the app. View 'About...' to opt out of sending the data. Add ability to view health-status for Jamf Cloud hosted instances. Fix an issue with token renewal. - -2023-12-06: v2.4.1 - Minor updates to the alerts display. - -2023-11-11: v2.4.0 - Fix issue with notifications not being displayed. Add ability to use API client. - -2023-04-07: v2.3.6 - Update logging to prevent potential looping. - -2022-10-02: v2.3.2 - Rework authentication/token refresh. - -2022-06-12: v2.3.1 - Clean up notificatations not displaying properly. - -2021-10-15: v2.3.0 - Updated notifications display. diff --git a/jamfStatus.xcodeproj/project.pbxproj b/jamfStatus.xcodeproj/project.pbxproj index 675f60a..86fd781 100644 --- a/jamfStatus.xcodeproj/project.pbxproj +++ b/jamfStatus.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ "$(SDKROOT)/usr/lib/system/introspection", ); MACOSX_DEPLOYMENT_TARGET = 12.4; - MARKETING_VERSION = 2.5.0; + MARKETING_VERSION = 2.5.4; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.pse.jamfStatus; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -441,7 +441,7 @@ "$(SDKROOT)/usr/lib/system/introspection", ); MACOSX_DEPLOYMENT_TARGET = 12.4; - MARKETING_VERSION = 2.5.0; + MARKETING_VERSION = 2.5.4; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.pse.jamfStatus; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/jamfStatus/AppDelegate.swift b/jamfStatus/AppDelegate.swift index 84b5a80..017e123 100644 --- a/jamfStatus/AppDelegate.swift +++ b/jamfStatus/AppDelegate.swift @@ -9,8 +9,15 @@ import WebKit @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NotificationCenter.default.addObserver(self, selector: #selector(updateHealthStatusView(_:)), name: .updateHealthStatusView, object: nil) + } + @IBOutlet weak var cloudStatus_Toolbar: NSToolbar! @IBOutlet weak var cloudStatusWindow: NSWindow! + @IBOutlet weak var healthStatus_Window: NSWindow! + + @IBOutlet weak var showHealthStatus_MenuItem: NSMenuItem! @IBOutlet var page_WebView: WKWebView! @IBOutlet weak var prefs_Window: NSWindow! @@ -50,10 +57,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDelegate { TelemetryDeckConfig.OptOut = (sender.state == .on) } - - - @IBOutlet weak var healthStatus_Window: NSWindow! - let prefs = Preferences.self let fm = FileManager() @@ -170,17 +173,31 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDelegate { } } - @IBAction func credentials_Action(_ sender: Any) { - JamfProServer.url = jamfServerUrl_TextField.stringValue - - let urlRegex = try! NSRegularExpression(pattern: "/?failover(.*?)", options:.caseInsensitive) - JamfProServer.url = urlRegex.stringByReplacingMatches(in: JamfProServer.url, options: [], range: NSRange(0.. + @@ -117,7 +118,7 @@ - + @@ -158,7 +159,7 @@ - + @@ -491,7 +492,7 @@ - + @@ -506,7 +507,7 @@ - + @@ -615,7 +616,7 @@ - + @@ -662,7 +663,7 @@ - + @@ -713,12 +714,12 @@ - + - - + + @@ -776,7 +777,7 @@ - + @@ -788,7 +789,7 @@ - + @@ -800,7 +801,7 @@ - + @@ -812,7 +813,7 @@ - + @@ -824,7 +825,7 @@ - + @@ -884,7 +885,7 @@ - + @@ -896,7 +897,7 @@ - + @@ -908,7 +909,7 @@ - + @@ -920,7 +921,7 @@ - + @@ -932,7 +933,7 @@ - + @@ -1000,7 +1001,7 @@ - + @@ -1012,7 +1013,7 @@ - + @@ -1024,7 +1025,7 @@ - + @@ -1036,7 +1037,7 @@ - + @@ -1048,7 +1049,7 @@ - + @@ -1108,7 +1109,7 @@ - + @@ -1120,7 +1121,7 @@ - + @@ -1132,7 +1133,7 @@ - + @@ -1144,7 +1145,7 @@ - + @@ -1156,7 +1157,7 @@ - + @@ -1216,7 +1217,7 @@ - + @@ -1228,7 +1229,7 @@ - + @@ -1240,7 +1241,7 @@ - + @@ -1252,7 +1253,7 @@ - + @@ -1264,7 +1265,7 @@ - + diff --git a/jamfStatus/Credentials2.swift b/jamfStatus/Credentials2.swift deleted file mode 100644 index c94344b..0000000 --- a/jamfStatus/Credentials2.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// Credentials2.swift -// jamfStatus -// -// Created by Leslie Helou on 9/11/19. -// Copyright © 2019 Leslie Helou. All rights reserved. -// - -import Foundation -import Security - -let kSecAttrAccountString = NSString(format: kSecAttrAccount) -let kSecValueDataString = NSString(format: kSecValueData) -let kSecClassGenericPasswordString = NSString(format: kSecClassGenericPassword) -let keychainQ = DispatchQueue(label: "com.jamf.objectinfo", qos: DispatchQoS.background) - -class Credentials2 { - - func save(service: String, account: String, data: String) { - - keychainQ.async { [self] in - if let password = data.data(using: String.Encoding.utf8) { - var keychainQuery: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecValueData as String: password] - - // see if credentials already exist for server - let accountCheck = retrieve(service: service) - if accountCheck.count == 0 { - // try to add new credentials, if account exists we'll try updating it - let addStatus = SecItemAdd(keychainQuery as CFDictionary, nil) - if (addStatus != errSecSuccess) { - if let addErr = SecCopyErrorMessageString(addStatus, nil) { - print("[addStatus] Write failed for new credentials: \(addErr)") - } - } - } else { - // credentials already exist, try to update - keychainQuery = [kSecClass as String: kSecClassGenericPasswordString, - kSecAttrService as String: service, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true] - let updateStatus = SecItemUpdate(keychainQuery as CFDictionary, [kSecAttrAccountString:account,kSecValueDataString:password] as CFDictionary) - if (updateStatus != errSecSuccess) { - if let updateErr = SecCopyErrorMessageString(updateStatus, nil) { - print("[updateStatus] Update failed for existing credentials: \(updateErr)") - } - } - } - } - } - } // func save - end - - func retrieve(service: String) -> [String] { - - var storedCreds = [String]() - - let keychainQuery: [String: Any] = [kSecClass as String: kSecClassGenericPasswordString, - kSecAttrService as String: service, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true, - kSecReturnData as String: true] - var item: CFTypeRef? - let status = SecItemCopyMatching(keychainQuery as CFDictionary, &item) - guard status != errSecItemNotFound else { return [] } - guard status == errSecSuccess else { return [] } - - guard let existingItem = item as? [String : Any], - let passwordData = existingItem[kSecValueData as String] as? Data, - let account = existingItem[kSecAttrAccount as String] as? String, - let password = String(data: passwordData, encoding: String.Encoding.utf8) - else { - return [] - } - storedCreds.append(account) - storedCreds.append(password) - return storedCreds - } - -} diff --git a/jamfStatus/Globals.swift b/jamfStatus/Globals.swift index 0ba9cdb..590d553 100644 --- a/jamfStatus/Globals.swift +++ b/jamfStatus/Globals.swift @@ -33,6 +33,7 @@ var isDarkMode: Bool { return mode == "Dark" } var defaultTextColor = isDarkMode ? NSColor.white:NSColor.black +var refreshHealthStatus: Bool = false struct JamfNotification { static let key = ["TOMCAT_SSL_CERT_EXPIRED":"CERT_EXPIRED", @@ -189,49 +190,62 @@ struct Preferences { static var menuIconStyle = "color" } +public func healthStatusIsVisible() -> Bool { + refreshHealthStatus = false -struct token { - static var refreshInterval:UInt32 = 10*60 // 10 minutes - static var sourceServer = "" - static var sourceExpires = "" - static var startTime = Date() - static var isValid = false + let options: CGWindowListOption = [.excludeDesktopElements, .optionOnScreenOnly] + guard let windowListInfo = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[CFString: Any]] else { + return false + } + + refreshHealthStatus = windowListInfo.contains { windowInfo in + guard let ownerName = windowInfo[kCGWindowOwnerName] as? String, + let windowName = windowInfo[kCGWindowName] as? String else { + return false + } + return ownerName == "jamfStatus" && windowName == "Health Status" + } + return refreshHealthStatus } public func timeDiff(startTime: Date) -> (Int, Int, Int, Double) { let endTime = Date() -// let components = Calendar.current.dateComponents([.second, .nanosecond], from: startTime, to: endTime) -// let timeDifference = Double(components.second!) + Double(components.nanosecond!)/1000000000 -// WriteToLog().message(stringOfText: "[ViewController.download] time difference: \(timeDifference) seconds") let components = Calendar.current.dateComponents([ .hour, .minute, .second, .nanosecond], from: startTime, to: endTime) var diffInSeconds = Double(components.hour!)*3600 + Double(components.minute!)*60 + Double(components.second!) + Double(components.nanosecond!)/1000000000 diffInSeconds = Double(round(diffInSeconds * 1000) / 1000) -// let timeDifference = Int(components.second!) //+ Double(components.nanosecond!)/1000000000 -// let (h,r) = timeDifference.quotientAndRemainder(dividingBy: 3600) -// let (m,s) = r.quotientAndRemainder(dividingBy: 60) -// WriteToLog().message(stringOfText: "[ViewController.download] download time: \(h):\(m):\(s) (h:m:s)") + return (Int(components.hour!), Int(components.minute!), Int(components.second!), diffInSeconds) -// return (h, m, s) } extension String { - var fqdnFromUrl: String { + var baseUrl: String { get { - var fqdn = "" - let nameArray = self.components(separatedBy: "/") - if nameArray.count > 2 { - fqdn = nameArray[2] - } else { - fqdn = self + guard let url = URL(string: self), + let scheme = url.scheme, + let host = url.host else { + return "" } - if fqdn.contains(":") { - let fqdnArray = fqdn.components(separatedBy: ":") - fqdn = fqdnArray[0] + + // Construct base URL with scheme and host + var baseURL = "\(scheme)://\(host)" + + // Include port if it exists and is not default + if let port = url.port { + baseURL += ":\(port)" + } + + return baseURL + } + } + var fqdn: String { + get { + guard let url = URL(string: self), + let host = url.host else { + return "" } - let urlRegex = try! NSRegularExpression(pattern: "/?failover(.*?)", options:.caseInsensitive) - fqdn = urlRegex.stringByReplacingMatches(in: fqdn, options: [], range: NSRange(0.. Void) { diff --git a/jamfStatus/TokenManager.swift b/jamfStatus/TokenManager.swift index 53e142e..a5d1986 100644 --- a/jamfStatus/TokenManager.swift +++ b/jamfStatus/TokenManager.swift @@ -104,6 +104,7 @@ actor TokenManager { } JamfProServer.accessToken = newTokenInfo.token + JamfProServer.validToken = true Logger.check.debug("granted new token, expires \(newTokenInfo.expiresAt, privacy: .public)") await MainActor.run { diff --git a/jamfStatus/UapiCall.swift b/jamfStatus/UapiCall.swift index 94fb66c..507bc8f 100644 --- a/jamfStatus/UapiCall.swift +++ b/jamfStatus/UapiCall.swift @@ -11,6 +11,7 @@ import Foundation +import OSLog class UapiCall: NSObject, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate { @@ -19,7 +20,7 @@ class UapiCall: NSObject, URLSessionDelegate, URLSessionDataDelegate, URLSession func get(endpoint: String, completion: @escaping (_ notificationAlerts: [Dictionary]) -> Void) { Task { - if await TokenManager.shared.tokenInfo?.renewToken ?? true { + if await TokenManager.shared.tokenInfo?.renewToken ?? true || !JamfProServer.validToken { await TokenManager.shared.setToken(serverUrl: JamfProServer.url, username: JamfProServer.username.lowercased(), password: JamfProServer.password) } @@ -49,22 +50,32 @@ class UapiCall: NSObject, URLSessionDelegate, URLSessionDataDelegate, URLSession (data, response, error) -> Void in if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode >= 200 && httpResponse.statusCode <= 299 { - let json = try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) - if let notificationsDictArray = json! as? [[String: Any]] { - completion(notificationsDictArray) - return - } else { // if let endpointJSON error - print("[UapiCall] get JSON error") - completion([]) - return + do { + let json = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) + if let notificationsDictArray = json as? [[String: Any]] { + completion(notificationsDictArray) + return + } else { + Logger.jpapi.info("An error creating notifications array") + let dataString = String(data: data!, encoding: .utf8) ?? "No data" + Logger.jpapi.info("returned data: \(dataString, privacy: .public)") + completion([]) + return + } + } catch { + Logger.jpapi.info("An error parsing notification data occurred: \(error.localizedDescription, privacy: .public)") } } else { // if httpResponse.statusCode <200 or >299 - print("[UapiCall] \(endpoint) - get response error: \(httpResponse.statusCode)") + Logger.jpapi.debug("\(endpoint, privacy: .public) - get response error: \(httpResponse.statusCode, privacy: .public)") + if httpResponse.statusCode == 401 { + JamfProServer.accessToken = "" + JamfProServer.validToken = false + } completion([]) return } } else { - print("\n HTTP error \n") + Logger.jpapi.info("An error occurred: \(error!.localizedDescription, privacy: .public)") } }) task.resume()