/
MonitoredApp.swift
310 lines (289 loc) · 12.3 KB
/
MonitoredApp.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
import AppKit
enum MonitoredApp: String, CaseIterable {
case arcbrowser = "company.thebrowser.Browser"
case brave = "com.brave.Browser"
case canva = "com.canva.CanvaDesktop"
case chrome = "com.google.Chrome"
case figma = "com.figma.Desktop"
case firefox = "org.mozilla.firefox"
case imessage = "com.apple.MobileSMS"
case iterm2 = "com.googlecode.iterm2"
case linear = "com.linear"
case notes = "com.apple.Notes"
case notion = "notion.id"
case postman = "com.postmanlabs.mac"
case safari = "com.apple.Safari"
case safaripreview = "com.apple.SafariTechnologyPreview"
case slack = "com.tinyspeck.slackmacgap"
case tableplus = "com.tinyapp.TablePlus"
case terminal = "com.apple.Terminal"
case warp = "dev.warp.Warp-Stable"
case wecom = "com.tencent.WeWorkMac"
case whatsapp = "net.whatsapp.WhatsApp"
case xcode = "com.apple.dt.Xcode"
case zoom = "us.zoom.xos"
init?(from bundleId: String) {
if let app = MonitoredApp(rawValue: bundleId) {
self = app
} else if let app = MonitoredApp(rawValue: bundleId.replacingOccurrences(of: "-setapp$", with: "", options: .regularExpression)) {
self = app
} else {
return nil
}
}
static var allBundleIds: [String] {
MonitoredApp.allCases.map { $0.rawValue }
}
static let electronAppIds = [
MonitoredApp.figma.rawValue,
MonitoredApp.slack.rawValue,
]
static let browserAppIds = [
MonitoredApp.arcbrowser.rawValue,
MonitoredApp.brave.rawValue,
MonitoredApp.chrome.rawValue,
MonitoredApp.firefox.rawValue,
MonitoredApp.safari.rawValue,
MonitoredApp.safaripreview.rawValue,
]
// list apps which are enabled by default on first run
static let defaultEnabledApps = [
MonitoredApp.canva.rawValue,
MonitoredApp.figma.rawValue,
MonitoredApp.linear.rawValue,
MonitoredApp.notes.rawValue,
MonitoredApp.notion.rawValue,
MonitoredApp.postman.rawValue,
MonitoredApp.tableplus.rawValue,
MonitoredApp.xcode.rawValue,
MonitoredApp.zoom.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 .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
}
}
func project(for element: AXUIElement) -> String? {
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..<url.endIndex, in: url)
if let match = regex.firstMatch(in: url, options: [], range: nsrange) {
// Adjusted to capture the right group based on the pattern.
// The group index might be 2 if the pattern includes a platform prefix before the project name.
let groupIndex = pattern.contains("(github|bitbucket|gitlab)") ? 2 : 1
let range = match.range(at: groupIndex)
if range.location != NSNotFound, let range = Range(range, in: url) {
return String(url[range])
}
}
} catch {
Logging.default.log("Regex error: \(error)")
continue
}
}
// Return nil if no pattern matches
return nil
}
var language: String? {
switch self {
case .figma:
return "Figma Design"
case .postman:
return "HTTP Request"
default:
return nil
}
}
func currentBrowserUrl(for element: AXUIElement) -> 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 = element.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 = element.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 .imessage:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .iterm2:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .linear:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .notes:
fatalError("\(self.rawValue) should never use window title as entity")
case .notion:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .postman:
guard
let title = element.extractPrefix(element.rawTitle, separator: " - ", fullTitle: true),
title != "Postman"
else { return nil }
return title
case .slack:
return element.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 element.extractPrefix(element.rawTitle, separator: " - ")
case .terminal:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .warp:
guard
let title = element.extractPrefix(element.rawTitle, separator: " - "),
title != "Warp"
else { return nil }
return title
case .wecom:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .whatsapp:
return element.extractPrefix(element.rawTitle, separator: " - ")
case .xcode:
fatalError("\(self.rawValue) should never use window title as entity")
case .zoom:
return element.extractPrefix(element.rawTitle, separator: " - ")
}
}
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)"
}
}