diff --git a/WakaTime/Helpers/MonitoringManager.swift b/WakaTime/Helpers/MonitoringManager.swift index 22e16f4..12e2a90 100644 --- a/WakaTime/Helpers/MonitoringManager.swift +++ b/WakaTime/Helpers/MonitoringManager.swift @@ -8,16 +8,6 @@ class MonitoringManager { } static func isAppMonitored(for bundleId: String) -> Bool { - guard - MonitoredApp.allBundleIds.contains(bundleId) || - MonitoredApp.allBundleIds.contains(bundleId.replacingOccurrences(of: "-setapp$", with: "", options: .regularExpression)) - else { return false } - - guard - !MonitoredApp.unsupportedAppIds.contains(bundleId), - !MonitoredApp.unsupportedAppIds.contains(bundleId.replacingOccurrences(of: "-setapp$", with: "", options: .regularExpression)) - else { return false } - let isMonitoredKey = monitoredKey(bundleId: bundleId) if UserDefaults.standard.string(forKey: isMonitoredKey) != nil { @@ -65,16 +55,17 @@ class MonitoringManager { let pid = app.processIdentifier guard - let monitoredApp = app.monitoredApp, let activeWindow = AXUIElementCreateApplication(pid).activeWindow, - let entity = monitoredApp.entity(for: activeWindow, app) + let entity = entity(for: app, activeWindow), + let entityUnwrapped = entity.0 else { return nil } return HeartbeatData( - entity: entity, - project: monitoredApp.project(for: activeWindow), - language: monitoredApp.language, - category: monitoredApp.category + entity: entityUnwrapped, + entityType: entity.1, + project: project(for: app, activeWindow), + language: language(for: app), + category: category(for: app) ) } @@ -109,10 +100,425 @@ class MonitoringManager { static func monitoredKey(bundleId: String) -> String { "is_\(bundleId)_monitored" } + + static func entity(for app: NSRunningApplication, _ element: AXUIElement) -> (String?, EntityType)? { + if MonitoringManager.isAppBrowser(app) { + guard + let url = currentBrowserUrl(for: app, element), + FilterManager.filterBrowsedSites(url) + else { return nil } + + guard PropertiesManager.domainPreference == .domain else { return (url, .url) } + + return (domainFromUrl(url), .domain) + } + + guard let monitoredApp = app.monitoredApp else { return (title(for: app, element), .app) } + + switch monitoredApp { + case .canva: + // Canva obviously implements tabs in a different way than the tab content UI. + // Due to this circumstance, it's possible to just sample an element from the + // Canva window which is positioned underneath the tab bar and trace to the + // web area root which appears to be properly titled. All the UI zoom settings + // in Canva only change the tab content or sub content of the tab content, hence + // this should be relatively safe. In cases where this fails, nil should be + // returned as a consequence of the web area not being found. + let someElem = element.elementAtPositionRelativeToWindow(x: 10, y: 60) + let webArea = someElem?.firstAncestorWhere { $0.role == "AXWebArea" } + return (webArea?.rawTitle, .app) + case .notes: + // There's apparently two text editor implementations in Apple Notes. One uses a web view, + // the other appears to be a native implementation based on the `ICTK2MacTextView` class. + let webAreaElement = element.firstDescendantWhere { $0.role == "AXWebArea" } + if let webAreaElement { + // WebView-based implementation + let titleElement = webAreaElement.firstDescendantWhere { $0.role == kAXStaticTextRole } + return (titleElement?.value, .app) + } else { + // ICTK2MacTextView + let textAreaElement = element.firstDescendantWhere { $0.role == kAXTextAreaRole } + if let value = textAreaElement?.value { + let title = extractPrefix(value, separator: "\n") + return (title, .app) + } + return nil + } + default: + return (title(for: app, element), .app) + } + } + + // swiftlint:disable cyclomatic_complexity + static func title(for app: NSRunningApplication, _ element: AXUIElement) -> String? { + guard let monitoredApp = app.monitoredApp else { + return extractPrefix(element.rawTitle) + } + + switch monitoredApp { + case .adobeaftereffect: + return extractPrefix(element.rawTitle) + case .adobebridge: + return extractPrefix(element.rawTitle) + case .adobeillustrator: + return extractPrefix(element.rawTitle) + case .adobemediaencoder: + return extractPrefix(element.rawTitle) + case .adobephotoshop: + return extractPrefix(element.rawTitle) + case .adobepremierepro: + return extractPrefix(element.rawTitle) + case .arcbrowser: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .beeper: + return extractPrefix(element.rawTitle) + case .brave: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .canva: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .chrome: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .figma: + guard + let title = extractPrefix(element.rawTitle, separator: " – "), + title != "Figma", + title != "Drafts" + else { return nil } + return title + case .firefox: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .github: + return extractPrefix(element.rawTitle, separator: " - ") + case .imessage: + return extractPrefix(element.rawTitle, separator: " - ") + case .iterm2: + return extractPrefix(element.rawTitle, separator: " - ") + case .linear: + return extractPrefix(element.rawTitle, separator: " - ") + case .miro: + return extractSuffix(element.rawTitle) + case .notes: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .notion: + return extractPrefix(element.rawTitle, separator: " - ") + case .postman: + guard + let title = extractPrefix(element.rawTitle, separator: " - ", fullTitle: true), + title != "Postman" + else { return nil } + return title + case .rocketchat: + return extractPrefix(element.rawTitle) + case .slack: + return extractPrefix(element.rawTitle, separator: " - ") + case .safari: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .safaripreview: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .tableplus: + return extractPrefix(element.rawTitle, separator: " - ") + case .terminal: + return extractPrefix(element.rawTitle, separator: " - ") + case .warp: + guard + let title = extractPrefix(element.rawTitle, separator: " - "), + title != "Warp" + else { return nil } + return title + case .wecom: + return extractPrefix(element.rawTitle, separator: " - ") + case .whatsapp: + return extractPrefix(element.rawTitle, separator: " - ") + case .xcode: + fatalError("\(monitoredApp.rawValue) should never use window title as entity") + case .zoom: + return extractPrefix(element.rawTitle, separator: " - ") + case .zed: + return extractPrefix(element.rawTitle, separator: " — ") + } + } + + static func category(for app: NSRunningApplication) -> Category? { + guard let monitoredApp = app.monitoredApp else { return .coding } + + switch monitoredApp { + case .adobeaftereffect: + return .designing + case .adobebridge: + return .designing + case .adobeillustrator: + return .designing + case .adobemediaencoder: + return .designing + case .adobephotoshop: + return .designing + case .adobepremierepro: + return .designing + case .arcbrowser: + return .browsing + case .beeper: + return .communicating + case .brave: + return .browsing + case .canva: + return .designing + case .chrome: + return .browsing + case .figma: + return .designing + case .firefox: + return .browsing + case .github: + return .codereviewing + case .imessage: + return .communicating + case .iterm2: + return .coding + case .linear: + return .planning + case .miro: + return .planning + case .notes: + return .writingdocs + case .notion: + return .writingdocs + case .postman: + return .debugging + case .rocketchat: + return .communicating + case .slack: + return .communicating + case .safari: + return .browsing + case .safaripreview: + return .browsing + case .tableplus: + return .debugging + case .terminal: + return .coding + case .warp: + return .coding + case .wecom: + return .communicating + case .whatsapp: + return .meeting + case .xcode: + fatalError("\(monitoredApp.rawValue) should never use window title") + case .zoom: + return .meeting + case .zed: + return .coding + } + } + // swiftlint:enable cyclomatic_complexity + + static func project(for app: NSRunningApplication, _ element: AXUIElement) -> String? { + guard let monitoredApp = app.monitoredApp else { + guard let url = currentBrowserUrl(for: app, element) else { return nil } + return project(from: url) + } + + // TODO: detect repo from GitHub Desktop Client if possible + switch monitoredApp { + case .slack: + return extractSuffix(element.rawTitle, separator: " - ", offset: 1) + case .zed: + return extractSuffix(element.rawTitle, separator: " — ") + default: + guard let url = currentBrowserUrl(for: app, element) else { return nil } + return project(from: url) + } + } + + static func project(from url: String) -> String? { + let patterns = [ + "github.com/([^/]+/[^/]+)/?.*$", + "bitbucket.org/([^/]+/[^/]+)/?.*$", + "app.circleci.com/.*/?(github|bitbucket|gitlab)/([^/]+/[^/]+)/?.*$", + "app.travis-ci.com/(github|bitbucket|gitlab)/([^/]+/[^/]+)/?.*$", + "app.travis-ci.org/(github|bitbucket|gitlab)/([^/]+/[^/]+)/?.*$" + ] + + for pattern in patterns { + do { + let regex = try NSRegularExpression(pattern: pattern) + let nsrange = NSRange(url.startIndex.. String? { + guard let monitoredApp = app.monitoredApp else { return nil } + + switch monitoredApp { + case .figma: + return "Figma Design" + case .postman: + return "HTTP Request" + default: + return nil + } + } + + static func currentBrowserUrl(for app: NSRunningApplication, _ element: AXUIElement) -> String? { + guard let monitoredApp = app.monitoredApp else { return nil } + + var address: String? + switch monitoredApp { + case .brave: + let addressField = element.findAddressField() + address = addressField?.value + case .chrome: + let addressField = element.findAddressField() + address = addressField?.value + case .firefox: + let addressField = element.findAddressField() + address = addressField?.value + case .linear: + let projectLabel = element.firstDescendantWhere { $0.value == "Project" } + let projectButton = projectLabel?.nextSibling?.firstDescendantWhere { $0.role == kAXButtonRole } + return projectButton?.rawTitle + case .safari: + let addressField = element.elementById(identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD") + address = addressField?.value + case .safaripreview: + let addressField = element.elementById(identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD") + address = addressField?.value + default: return nil + } + return address + } + + static func extractPrefix(_ str: String?, separator: String? = nil, minCount: Int? = nil, fullTitle: Bool = false) -> String? { + guard let str = str else { return nil } + + guard let separator = separator else { + return getFirstPrefixMatch(str) + } + + let parts = str.components(separatedBy: separator) + guard !parts.isEmpty else { return nil } + guard let item = parts.first else { return nil } + + if let minCount = minCount, minCount > 0, parts.count < minCount { + return nil + } + + if item.trimmingCharacters(in: .whitespacesAndNewlines) != "" { + if fullTitle { + return str.trimmingCharacters(in: .whitespacesAndNewlines) + } + return item.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + static func extractSuffix(_ str: String?, separator: String? = nil, offset: Int = 0) -> String? { + guard let str = str else { return nil } + + guard let separator = separator else { + return getFirstSuffixMatch(str) + } + + var parts = str.components(separatedBy: separator) + guard !parts.isEmpty else { return nil } + guard parts.count > 1 else { return nil } + + var i = offset + while i > 0 { + guard parts.count > 1 else { return nil } + + parts.removeLast() + i += 1 + } + guard let item = parts.last else { return nil } + + if item.trimmingCharacters(in: .whitespacesAndNewlines) != "" { + return item.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return nil + } + + static func domainFromUrl(_ url: String) -> String? { + guard let host = URL(stringWithoutScheme: url)?.host else { return nil } + let domain = host.replacingOccurrences(of: "^www.", with: "", options: .regularExpression) + guard let port = URL(stringWithoutScheme: url)?.port else { return domain } + return "\(domain):\(port)" + } + + static let separators = [ + "-", + "᠆", + "‐", + "‑", + "‒", + "–", + "—", + "―", + "⸺", + "⸻", + "︱", + "︲", + "﹘", + "﹣", + "-", + ] + + static func getFirstPrefixMatch(_ str: String) -> String { + guard !str.isEmpty else { return str.trimmingCharacters(in: .whitespacesAndNewlines) } + + for separator in separators { + let parts = str.components(separatedBy: separator) + guard parts.count > 1 else { continue } + guard let item = parts.first else { continue } + + let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + return trimmed + } + + return str.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func getFirstSuffixMatch(_ str: String) -> String { + guard !str.isEmpty else { return str.trimmingCharacters(in: .whitespacesAndNewlines) } + + for separator in separators { + let parts = str.components(separatedBy: separator) + guard parts.count > 1 else { continue } + guard let item = parts.last else { continue } + + let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + return trimmed + } + + return str.trimmingCharacters(in: .whitespacesAndNewlines) + } } struct HeartbeatData { var entity: String + var entityType: EntityType var project: String? var language: String? var category: Category? diff --git a/WakaTime/Views/MonitoredAppsView.swift b/WakaTime/Views/MonitoredAppsView.swift index 31c9cea..474ad96 100644 --- a/WakaTime/Views/MonitoredAppsView.swift +++ b/WakaTime/Views/MonitoredAppsView.swift @@ -9,9 +9,10 @@ class MonitoredAppsView: NSView, NSOutlineViewDataSource, NSOutlineViewDelegate } private var outlineView: NSOutlineView! + private lazy var apps: [AppData] = { var apps = [AppData]() - let bundleIds = MonitoredApp.allBundleIds.filter { !MonitoredApp.unsupportedAppIds.contains($0) } + let bundleIds = sort(MonitoredApp.allBundleIds + runningApps()) var index = 0 for bundleId in bundleIds { if let icon = AppInfo.getIcon(bundleId: bundleId), @@ -30,6 +31,31 @@ class MonitoredAppsView: NSView, NSOutlineViewDataSource, NSOutlineViewDelegate return apps }() + private func runningApps() -> [String] { + var ids: [String] = [] + for runningApp in NSWorkspace.shared.runningApplications where runningApp.activationPolicy == .regular { + guard let id = runningApp.bundleIdentifier else { continue } + + let bundleId = id.replacingOccurrences(of: "-setapp$", with: "", options: .regularExpression) + + guard + !MonitoredApp.unsupportedAppIds.contains(where: { $0 == bundleId }), + !MonitoredApp.allBundleIds.contains(where: { $0 == bundleId }) + else { continue } + + ids.append(bundleId) + } + return ids + } + + private func sort(_ bundleIds: [String]) -> [String] { + bundleIds.sorted { + let left = AppInfo.getAppName(bundleId: $0) ?? $0 + let right = AppInfo.getAppName(bundleId: $1) ?? $1 + return left.localizedCaseInsensitiveCompare(right) == ComparisonResult.orderedAscending + } + } + override init(frame frameRect: NSRect) { super.init(frame: frameRect) diff --git a/WakaTime/WakaTime.swift b/WakaTime/WakaTime.swift index 1dc3217..1e0c91d 100644 --- a/WakaTime/WakaTime.swift +++ b/WakaTime/WakaTime.swift @@ -180,6 +180,8 @@ enum DeepLink: String { enum EntityType: String { case file case app + case domain + case url } enum Category: String { diff --git a/WakaTime/Watchers/MonitoredApp.swift b/WakaTime/Watchers/MonitoredApp.swift index 828936b..71c3e20 100644 --- a/WakaTime/Watchers/MonitoredApp.swift +++ b/WakaTime/Watchers/MonitoredApp.swift @@ -1,7 +1,14 @@ import AppKit enum MonitoredApp: String, CaseIterable { + case adobeaftereffect = "com.adobe.AfterEffects" + case adobebridge = "com.adobe.bridge14" + case adobeillustrator = "com.adobe.illustrator" + case adobemediaencoder = "com.adobe.ame.application.24" + case adobephotoshop = "com.adobe.Photoshop" + case adobepremierepro = "com.adobe.PremierePro.24" case arcbrowser = "company.thebrowser.Browser" + case beeper = "im.beeper" case brave = "com.brave.Browser" case canva = "com.canva.CanvaDesktop" case chrome = "com.google.Chrome" @@ -11,9 +18,11 @@ enum MonitoredApp: String, CaseIterable { case imessage = "com.apple.MobileSMS" case iterm2 = "com.googlecode.iterm2" case linear = "com.linear" + case miro = "com.electron.realtimeboard" case notes = "com.apple.Notes" case notion = "notion.id" case postman = "com.postmanlabs.mac" + case rocketchat = "chat.rocket" case safari = "com.apple.Safari" case safaripreview = "com.apple.SafariTechnologyPreview" case slack = "com.tinyspeck.slackmacgap" @@ -36,6 +45,12 @@ enum MonitoredApp: String, CaseIterable { } } + // Hide these from the Monitored Apps menu + static let unsupportedAppIds = [ + "macos-wakatime.WakaTime", + "com.apple.finder", + ] + static var allBundleIds: [String] { MonitoredApp.allCases.map { $0.rawValue } } @@ -68,306 +83,4 @@ enum MonitoredApp: String, CaseIterable { MonitoredApp.zoom.rawValue, MonitoredApp.zed.rawValue, ] - - // list apps which we aren't yet able to track, so they're hidden from the Monitored Apps menu - static let unsupportedAppIds = [String]() - - var category: Category? { - switch self { - case .arcbrowser: - return .browsing - case .brave: - return .browsing - case .canva: - return .designing - case .chrome: - return .browsing - case .figma: - return .designing - case .firefox: - return .browsing - case .github: - return .codereviewing - case .imessage: - return .communicating - case .iterm2: - return .coding - case .linear: - return .planning - case .notes: - return .writingdocs - case .notion: - return .writingdocs - case .postman: - return .debugging - case .slack: - return .communicating - case .safari: - return .browsing - case .safaripreview: - return .browsing - case .tableplus: - return .debugging - case .terminal: - return .coding - case .warp: - return .coding - case .wecom: - return .communicating - case .whatsapp: - return .meeting - case .xcode: - fatalError("\(rawValue) should never use window title") - case .zoom: - return .meeting - case .zed: - return .coding - } - } - - func project(for element: AXUIElement) -> String? { - // TODO: detect repo from GitHub Desktop Client if possible - switch self { - case .slack: - return extractSuffix(element.rawTitle, separator: " - ", offset: 1) - case .zed: - return extractSuffix(element.rawTitle, separator: " — ") - default: - guard let url = currentBrowserUrl(for: element) else { return nil } - return project(from: url) - } - } - - private func project(from url: String) -> String? { - let patterns = [ - "github.com/([^/]+/[^/]+)/?.*$", - "bitbucket.org/([^/]+/[^/]+)/?.*$", - "app.circleci.com/.*/?(github|bitbucket|gitlab)/([^/]+/[^/]+)/?.*$", - "app.travis-ci.com/(github|bitbucket|gitlab)/([^/]+/[^/]+)/?.*$", - "app.travis-ci.org/(github|bitbucket|gitlab)/([^/]+/[^/]+)/?.*$" - ] - - for pattern in patterns { - do { - let regex = try NSRegularExpression(pattern: pattern) - let nsrange = NSRange(url.startIndex.. String? { - var address: String? - switch self { - case .brave: - let addressField = element.findAddressField() - address = addressField?.value - case .chrome: - let addressField = element.findAddressField() - address = addressField?.value - case .firefox: - let addressField = element.findAddressField() - address = addressField?.value - case .linear: - let projectLabel = element.firstDescendantWhere { $0.value == "Project" } - let projectButton = projectLabel?.nextSibling?.firstDescendantWhere { $0.role == kAXButtonRole } - return projectButton?.rawTitle - case .safari: - let addressField = element.elementById(identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD") - address = addressField?.value - case .safaripreview: - let addressField = element.elementById(identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD") - address = addressField?.value - default: return nil - } - return address - } - - func entity(for element: AXUIElement, _ app: NSRunningApplication) -> String? { - if MonitoringManager.isAppBrowser(app) { - guard - let url = currentBrowserUrl(for: element), - FilterManager.filterBrowsedSites(url) - else { return nil } - - guard PropertiesManager.domainPreference == .domain else { return url } - - return domainFromUrl(url) - } - - switch self { - case .canva: - // Canva obviously implements tabs in a different way than the tab content UI. - // Due to this circumstance, it's possible to just sample an element from the - // Canva window which is positioned underneath the tab bar and trace to the - // web area root which appears to be properly titled. All the UI zoom settings - // in Canva only change the tab content or sub content of the tab content, hence - // this should be relatively safe. In cases where this fails, nil should be - // returned as a consequence of the web area not being found. - let someElem = element.elementAtPositionRelativeToWindow(x: 10, y: 60) - let webArea = someElem?.firstAncestorWhere { $0.role == "AXWebArea" } - return webArea?.rawTitle - case .notes: - // There's apparently two text editor implementations in Apple Notes. One uses a web view, - // the other appears to be a native implementation based on the `ICTK2MacTextView` class. - let webAreaElement = element.firstDescendantWhere { $0.role == "AXWebArea" } - if let webAreaElement { - // WebView-based implementation - let titleElement = webAreaElement.firstDescendantWhere { $0.role == kAXStaticTextRole } - return titleElement?.value - } else { - // ICTK2MacTextView - let textAreaElement = element.firstDescendantWhere { $0.role == kAXTextAreaRole } - if let value = textAreaElement?.value { - let title = extractPrefix(value, separator: "\n") - return title - } - return nil - } - default: - return title(for: element) - } - } - - func title(for element: AXUIElement) -> String? { - switch self { - case .arcbrowser: - fatalError("\(self.rawValue) should never use window title as entity") - case .brave: - fatalError("\(self.rawValue) should never use window title as entity") - case .canva: - fatalError("\(self.rawValue) should never use window title as entity") - case .chrome: - fatalError("\(self.rawValue) should never use window title as entity") - case .figma: - guard - let title = extractPrefix(element.rawTitle, separator: " – "), - title != "Figma", - title != "Drafts" - else { return nil } - return title - case .firefox: - fatalError("\(self.rawValue) should never use window title as entity") - case .github: - return extractPrefix(element.rawTitle, separator: " - ") - case .imessage: - return extractPrefix(element.rawTitle, separator: " - ") - case .iterm2: - return extractPrefix(element.rawTitle, separator: " - ") - case .linear: - return extractPrefix(element.rawTitle, separator: " - ") - case .notes: - fatalError("\(self.rawValue) should never use window title as entity") - case .notion: - return extractPrefix(element.rawTitle, separator: " - ") - case .postman: - guard - let title = extractPrefix(element.rawTitle, separator: " - ", fullTitle: true), - title != "Postman" - else { return nil } - return title - case .slack: - return extractPrefix(element.rawTitle, separator: " - ") - case .safari: - fatalError("\(self.rawValue) should never use window title as entity") - case .safaripreview: - fatalError("\(self.rawValue) should never use window title as entity") - case .tableplus: - return extractPrefix(element.rawTitle, separator: " - ") - case .terminal: - return extractPrefix(element.rawTitle, separator: " - ") - case .warp: - guard - let title = extractPrefix(element.rawTitle, separator: " - "), - title != "Warp" - else { return nil } - return title - case .wecom: - return extractPrefix(element.rawTitle, separator: " - ") - case .whatsapp: - return extractPrefix(element.rawTitle, separator: " - ") - case .xcode: - fatalError("\(self.rawValue) should never use window title as entity") - case .zoom: - return extractPrefix(element.rawTitle, separator: " - ") - case .zed: - return extractPrefix(element.rawTitle, separator: " — ") - } - } - - private func extractPrefix(_ str: String?, separator: String, minCount: Int? = nil, fullTitle: Bool = false) -> String? { - guard let str = str else { return nil } - - let parts = str.components(separatedBy: separator) - guard !parts.isEmpty else { return nil } - guard let item = parts.first else { return nil } - - if let minCount = minCount, minCount > 0, parts.count < minCount { - return nil - } - - if item.trimmingCharacters(in: .whitespacesAndNewlines) != "" { - if fullTitle { - return str.trimmingCharacters(in: .whitespacesAndNewlines) - } - return item.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - private func extractSuffix(_ str: String?, separator: String, offset: Int = 0) -> String? { - guard let str = str else { return nil } - - var parts = str.components(separatedBy: separator) - guard !parts.isEmpty else { return nil } - guard parts.count > 1 else { return nil } - - var i = offset - while i > 0 { - guard parts.count > 1 else { return nil } - - parts.removeLast() - i += 1 - } - guard let item = parts.last else { return nil } - - if item.trimmingCharacters(in: .whitespacesAndNewlines) != "" { - return item.trimmingCharacters(in: .whitespacesAndNewlines) - } - - return nil - } - - private func domainFromUrl(_ url: String) -> String? { - guard let host = URL(stringWithoutScheme: url)?.host else { return nil } - let domain = host.replacingOccurrences(of: "^www.", with: "", options: .regularExpression) - guard let port = URL(stringWithoutScheme: url)?.port else { return domain } - return "\(domain):\(port)" - } } diff --git a/WakaTime/Watchers/Watcher.swift b/WakaTime/Watchers/Watcher.swift index 206d1ab..fc01097 100644 --- a/WakaTime/Watchers/Watcher.swift +++ b/WakaTime/Watchers/Watcher.swift @@ -135,7 +135,7 @@ class Watcher: NSObject { self?.heartbeatEventHandler?.handleHeartbeatEvent( app: app, entity: heartbeat.entity, - entityType: EntityType.app, + entityType: heartbeat.entityType, project: heartbeat.project, language: heartbeat.language, category: heartbeat.category, @@ -235,7 +235,7 @@ class Watcher: NSObject { self.heartbeatEventHandler?.handleHeartbeatEvent( app: app, entity: path.path, - entityType: EntityType.file, + entityType: .file, project: nil, language: nil, category: self.isBuilding ? Category.building : Category.coding,