-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinit.lua
More file actions
421 lines (366 loc) · 13.8 KB
/
Copy pathinit.lua
File metadata and controls
421 lines (366 loc) · 13.8 KB
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
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
--- === AutoAllowScreenCapture ===
---
--- Automatically approves macOS's window picker screen/audio capture alert for a configurable list of trusted applications.
---
--- Match strategy: checks that the window is a genuine OS-presented
--- system dialog -- role AXWindow, subrole AXSystemDialog, owned by
--- com.apple.UserNotificationCenter -- then looks at its direct children
--- for an AXStaticText whose value exactly matches the expected sentence
--- for one of the configured app names.
---
--- Config file (OUTSIDE this Spoon, in your Hammerspoon config dir):
---
--- <hs.configdir>/Prefs/auto-allow-apps.json
---
---
--- Format:
--- { "appnames": ["Thaw", "RustDesk"], "loglevel": "info" }
---
--- appnames: array of app name strings (may be empty -- watcher just
--- won't install).
--- loglevel: 'nothing', 'error', 'warning', 'info', 'debug', or
--- 'verbose', or the corresponding number 0-5, per hs.logger's own
--- documented levels. Defaults to "info" if the file itself is
--- missing; but if the file EXISTS and loglevel is present but not
--- one of these, that's a hard validation failure (see below).
---
--- Validation / failure behavior: a malformed JSON file, a missing or
--- wrong-shaped "appnames", or an invalid "loglevel" all log a specific
--- error at error level and leave the Spoon inert -- no window watcher
--- gets installed. There is no live file-watching: change appnames or
--- loglevel, then call reloadApps() (or hs.reload()) to pick it up.
---
--- Debugging: logs through hs.logger under "AutoAllowSC" (dot syntax
--- required -- see hs.logger docs). Starts at "info" until a valid
--- config is loaded, which then applies the configured level.
---
--- spoon.AutoAllowScreenCapture.logger.setLogLevel("debug")
---
--- Test the label-matching logic against a plain string:
---
--- spoon.AutoAllowScreenCapture:testLabelMatch('"Thaw" is requesting to bypass the system private window picker and directly access your screen and audio.')
---
--- Test the full gate against a captured window object:
---
--- spoon.AutoAllowScreenCapture:testWindow(x[14])
local obj = {}
obj.__index = obj
obj.name = "AutoAllowScreenCapture"
obj.version = "1.1.0"
obj.author = "Darrin Tisdale"
obj.license = "MIT"
obj.homepage = "https://github.com/mdarrint/AutoAllowScreenCapture.spoon"
obj.logTag = "AllowSCapt"
obj.logger = hs.logger.new(obj.logTag, "info")
obj.configfilename = "auto-allow-apps.json"
-- The loaded app names, reachable from anywhere (after hs.loadSpoon) as
-- spoon.AutoAllowScreenCapture.apps.
obj.apps = {}
-- internal
obj._filter = nil
obj._matchStrings = {}
-- Delay (seconds) between moving the matched dialog off-screen and
-- actually pressing its Allow button. Gives other window watchers (e.g.
-- LuaSkin-based ones) a chance to observe the window before it's
-- dismissed -- dismissing it too quickly causes LuaSkin errors when a
-- later call tries to operate on a window that's already gone.
obj.dismissDelay = 0.25
-- local variables and functions
local EXPECTED_ROLE = "AXWindow"
local EXPECTED_SUBROLE = "AXSystemDialog"
local EXPECTED_BUNDLE_ID = "com.apple.UserNotificationCenter"
-- Valid log level names, per hs.logger's own documented list.
local VALID_LOG_LEVELS = {
["nothing"] = true,
["error"] = true,
["warning"] = true,
["info"] = true,
["debug"] = true,
["verbose"] = true,
}
local function expectedLabelFor(appName)
return "“" ..
appName ..
"” is requesting to bypass the system private window picker and directly access your screen and audio."
end
-- Validates a decoded loglevel value against hs.logger's documented
-- accepted forms. Returns the value to hand to setLogLevel() (a
-- canonicalized lowercase string, or the original number), or nil plus
-- an error string on failure.
local function validateLogLevel(value)
if type(value) == "string" then
local lowered = value:lower()
if VALID_LOG_LEVELS[lowered] then
return lowered, nil
end
return nil, "loglevel \"" .. value .. "\" is not one of: nothing, error, warning, info, debug, verbose"
elseif type(value) == "number" then
if value == math.floor(value) and value >= 0 and value <= 5 then
return value, nil
end
return nil, "loglevel " .. tostring(value) .. " is not an integer between 0 and 5"
else
return nil, "loglevel is missing or not a string/number (got " .. type(value) .. ")"
end
end
-- object private functions
-- Read + validate the JSON config file. On success returns
-- { apps = {...}, loglevel = <validated value> }. On ANY failure --
-- missing file, empty file, malformed JSON, wrong shape, invalid
-- loglevel -- logs a specific error and returns nil. Never throws.
function obj:_loadConfigFromDisk()
local path = self:configPath()
self.logger.i("reading config from " .. path)
local file = io.open(path, "r")
if not file then
self.logger.w("no config file at " .. path .. " -- watcher will be disabled")
return nil
end
local contents = file:read("*a")
file:close()
self.logger.d("read " .. tostring(contents and #contents or 0) .. " bytes from " .. path)
if not contents or contents:match("^%s*$") then
self.logger.w(path .. " is empty -- watcher will be disabled")
return nil
end
local ok, decoded = pcall(hs.json.decode, contents)
if not ok then
self.logger.e("failed to parse " .. path .. " as JSON: " .. tostring(decoded))
return nil
end
if type(decoded) ~= "table" then
self.logger.e(path .. " did not decode to a JSON object -- watcher will be disabled")
return nil
end
if type(decoded.appnames) ~= "table" then
self.logger.e(path .. " is missing an \"appnames\" array (or it isn't an array) -- watcher will be disabled")
return nil
end
local validatedLevel, levelErr = validateLogLevel(decoded.loglevel)
if not validatedLevel then
self.logger.e(path .. ": " .. levelErr .. " -- watcher will be disabled")
return nil
end
local names = {}
for _, entry in ipairs(decoded.appnames) do
if type(entry) == "string" and entry:match("%S") then
table.insert(names, entry)
else
self.logger.w("ignoring invalid entry in " .. path .. " appnames: " .. tostring(entry))
end
end
self.logger.i("loaded " .. #names .. " app name(s): " .. table.concat(names, ", "))
return { apps = names, loglevel = validatedLevel }
end
function obj:_isCandidateSystemDialog(win)
local role = win:role()
if role ~= EXPECTED_ROLE then
self.logger.d("_isCandidateSystemDialog(): role=" .. tostring(role) .. " (want " .. EXPECTED_ROLE .. ") -- skip")
return false
end
local subrole = win:subrole()
if subrole ~= EXPECTED_SUBROLE then
self.logger.d("_isCandidateSystemDialog(): subrole=" ..
tostring(subrole) .. " (want " .. EXPECTED_SUBROLE .. ") -- skip")
return false
end
local app = win:application()
if not app then
self.logger.d("_isCandidateSystemDialog(): no owning application -- skip")
return false
end
local bundleID = app:bundleID()
self.logger.d("_isCandidateSystemDialog(): observed bundleID=" .. tostring(bundleID))
if bundleID ~= EXPECTED_BUNDLE_ID then
self.logger.d("_isCandidateSystemDialog(): bundleID=" ..
tostring(bundleID) .. " (want " .. EXPECTED_BUNDLE_ID .. ") -- skip")
return false
end
return true
end
function obj:_findMatchingLabel(win)
local axWin = hs.axuielement.windowElement(win)
if not axWin then
self.logger.e("_findMatchingLabel(): could not get an AX element for the window")
return nil
end
for _, child in ipairs(axWin:attributeValue("AXChildren") or {}) do
if child:attributeValue("AXRole") == "AXStaticText" then
local value = child:attributeValue("AXValue")
self.logger.d("_findMatchingLabel(): label value -> " .. tostring(value))
for i, expected in ipairs(self._matchStrings) do
if value == expected then
return self.apps[i]
end
end
end
end
return nil
end
function obj:_pressAllow(win)
local axWin = hs.axuielement.windowElement(win)
if not axWin then
self.logger.e("_pressAllow(): could not get an AX element for the window")
return false
end
local children = axWin:attributeValue("AXChildren") or {}
self.logger.d("_pressAllow(): inspecting " .. #children .. " child element(s)")
for _, child in ipairs(children) do
local role = child:attributeValue("AXRole")
local title = child:attributeValue("AXTitle")
self.logger.d("_pressAllow(): child role=" .. tostring(role) .. " title=" .. tostring(title))
if role == "AXButton" and title == "Allow" then
child:performAction("AXPress")
self.logger.i("_pressAllow(): pressed Allow")
return true
end
end
self.logger.e("_pressAllow(): no \"Allow\" button found -- dialog left untouched")
return false
end
-- testing functions
function obj:testLabelMatch(value)
self.logger.i("testLabelMatch(): checking value -> " .. tostring(value))
for i, expected in ipairs(self._matchStrings) do
if value == expected then
self.logger.i("testLabelMatch(): MATCH -- app \"" .. self.apps[i] .. "\"")
return self.apps[i]
end
end
self.logger.i("testLabelMatch(): no match")
return nil
end
function obj:testWindow(win)
if not win then
self.logger.w("testWindow(): no window given")
return nil
end
if not self:_isCandidateSystemDialog(win) then
self.logger.i("testWindow(): not a candidate system dialog")
return nil
end
local matchedApp = self:_findMatchingLabel(win)
if matchedApp then
self.logger.i("testWindow(): MATCH -- app \"" .. matchedApp .. "\"")
else
self.logger.i("testWindow(): candidate dialog, but no configured app's label matched")
end
return matchedApp
end
--- AutoAllowScreenCapture:init()
--- Method
--- Initializes the Spoon. Called automatically by hs.loadSpoon().
---
--- Parameters:
--- * None
---
--- Returns:
--- * The AutoAllowScreenCapture object
function obj:init()
self.logger.i("init() starting")
local config = self:_loadConfigFromDisk()
if not config then
self.logger.e(
"configuration failed to load -- see error above; Spoon will not watch for anything until reloadApps() is called with a valid config")
self.apps = {}
self._matchStrings = {}
return self
end
self.logger.setLogLevel(config.loglevel)
self.logger.i("log level set to " .. tostring(config.loglevel))
self.apps = config.apps
self._matchStrings = {}
for _, appName in ipairs(self.apps) do
local expected = expectedLabelFor(appName)
table.insert(self._matchStrings, expected)
self.logger.d("expecting label: " .. expected)
end
self.logger.i("init() complete -- " .. #self._matchStrings .. " app(s) ready")
return self
end
--- AutoAllowScreenCapture:configPath()
--- Method
--- Provides the string for the configuration path
---
--- Parameters:
--- * None
---
--- Returns:
--- * a string for the path to the configuration file
function obj:configPath()
return hs.configdir .. "/Prefs/" .. self.configfilename
end
--- AutoAllowScreenCapture:start()
--- Method
--- Verifies the preferences were read, then starts hooking window creation to circumvent
--- the indicated notifications
---
--- Parameters:
--- * None
---
--- Returns:
--- * The AutoAllowScreenCapture object
function obj:start()
if #self._matchStrings == 0 then
self.logger.w("no configured apps -- not installing a window watcher")
return self
end
self._filter = hs.window.filter.new(true)
self._filter:subscribe(hs.window.filter.windowCreated, function(win)
if not win then return end
self.logger.d("windowCreated: title=\"" .. (win:title() or "") ..
"\" role=" .. tostring(win:role()) .. " subrole=" .. tostring(win:subrole()))
if not self:_isCandidateSystemDialog(win) then
return
end
local matchedApp = self:_findMatchingLabel(win)
if matchedApp then
self.logger.i("matched app \"" .. matchedApp .. "\" -- moving off-screen and scheduling Allow press")
local moveOk, moveErr = pcall(function() win:setTopLeft({ x = -10000, y = -10000 }) end)
if not moveOk then
self.logger.w("failed to move window off-screen: " .. tostring(moveErr))
end
hs.timer.doAfter(self.dismissDelay, function() self:_pressAllow(win) end)
else
self.logger.d("candidate system dialog seen, but no configured app's label matched")
end
end)
self.logger.i("watcher started -- watching for: " .. table.concat(self.apps, ", "))
return self
end
--- AutoAllowScreenCapture:stop()
--- Method
--- Stops notification window monitoring
---
--- Parameters:
--- * None
---
--- Returns:
--- * The AutoAllowScreenCapture object
function obj:stop()
if self._filter then
self._filter:unsubscribeAll()
self._filter = nil
self.logger.i("watcher stopped")
end
return self
end
--- AutoAllowScreenCapture:reloadApps()
--- Method
--- Re-reads and re-validates the config file and restarts the watcher.
--- Required after any appnames or loglevel change -- there is no
--- automatic file watching.
---
--- Parameters:
--- * None
---
--- Returns:
--- * The AutoAllowScreenCapture object
function obj:reloadApps()
self.logger.i("reloadApps() -- reloading config and restarting watcher")
self:stop()
self:init()
self:start()
return self
end
return obj