From db051721b18655274f987be0b5d74cfae9475990 Mon Sep 17 00:00:00 2001 From: Brayo Date: Wed, 29 Jul 2026 11:33:08 +0300 Subject: [PATCH] fix(macos): treat screen lock as AFK HID idle time alone misses lock-screen sessions when input still occurs (mouse jiggle, background audio, etc.). Detect lock via CGSessionCopyCurrentDictionary / CGSSessionScreenIsLocked and force AFK while locked; do not return to not-afk until the screen is unlocked. Fixes #66 --- aw_watcher_afk/afk.py | 19 +++++++++++++------ aw_watcher_afk/macos.py | 29 ++++++++++++++++++++++++----- aw_watcher_afk/unix.py | 7 ++++++- aw_watcher_afk/windows.py | 7 ++++++- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/aw_watcher_afk/afk.py b/aw_watcher_afk/afk.py index 95263b7..ae88474 100755 --- a/aw_watcher_afk/afk.py +++ b/aw_watcher_afk/afk.py @@ -13,13 +13,13 @@ if system == "Windows": # noreorder - from .windows import seconds_since_last_input # fmt: skip + from .windows import seconds_since_last_input, is_screen_locked # fmt: skip elif system == "Darwin": # noreorder - from .macos import seconds_since_last_input # fmt: skip + from .macos import seconds_since_last_input, is_screen_locked # fmt: skip elif system == "Linux": # noreorder - from .unix import seconds_since_last_input # fmt: skip + from .unix import seconds_since_last_input, is_screen_locked # fmt: skip else: raise Exception(f"Unsupported platform: {system}") @@ -84,19 +84,26 @@ def heartbeat_loop(self): now = datetime.now(timezone.utc) seconds_since_input = seconds_since_last_input() + locked = is_screen_locked() last_input = now - timedelta(seconds=seconds_since_input) logger.debug(f"Seconds since last input: {seconds_since_input}") + logger.debug(f"Screen locked: {locked}") + # Screen lock means the user is away even if HID idle time is + # low (e.g. mouse jiggle, background audio). While locked we + # must not transition back to not-afk based on synthetic input. # If no longer AFK - if afk and seconds_since_input < self.settings.timeout: + if afk and not locked and seconds_since_input < self.settings.timeout: logger.info("No longer AFK") self.ping(afk, timestamp=last_input) afk = False # ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event) self.ping(afk, timestamp=last_input + td1ms) # If becomes AFK - elif not afk and seconds_since_input >= self.settings.timeout: - logger.info("Became AFK") + elif not afk and ( + seconds_since_input >= self.settings.timeout or locked + ): + logger.info("Became AFK" + (" (screen locked)" if locked else "")) self.ping(afk, timestamp=last_input) afk = True # ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event) diff --git a/aw_watcher_afk/macos.py b/aw_watcher_afk/macos.py index 5fd2717..aa3bbce 100755 --- a/aw_watcher_afk/macos.py +++ b/aw_watcher_afk/macos.py @@ -1,14 +1,33 @@ -from Quartz.CoreGraphics import (CGEventSourceSecondsSinceLastEventType, - kCGEventSourceStateHIDSystemState, - kCGAnyInputEventType) +from Quartz.CoreGraphics import ( + CGEventSourceSecondsSinceLastEventType, + CGSessionCopyCurrentDictionary, + kCGAnyInputEventType, + kCGEventSourceStateHIDSystemState, +) + + +def is_screen_locked() -> bool: + """Return True if the macOS screen/session is locked. + + Uses the CGSSessionScreenIsLocked key from the current CoreGraphics + session dictionary. That key is only present when the screen is locked + (e.g. loginwindow after Cmd-Ctrl-Q or after the lock delay). + """ + session = CGSessionCopyCurrentDictionary() + if not session: + return False + return bool(session.get("CGSSessionScreenIsLocked", False)) def seconds_since_last_input() -> float: - return CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState, kCGAnyInputEventType) + return CGEventSourceSecondsSinceLastEventType( + kCGEventSourceStateHIDSystemState, kCGAnyInputEventType + ) if __name__ == "__main__": from time import sleep + while True: sleep(1) - print(seconds_since_last_input()) + print(seconds_since_last_input(), is_screen_locked()) diff --git a/aw_watcher_afk/unix.py b/aw_watcher_afk/unix.py index 42a7b9f..7c44cad 100755 --- a/aw_watcher_afk/unix.py +++ b/aw_watcher_afk/unix.py @@ -43,7 +43,12 @@ def seconds_since_last_input(): return _last_input_unix.seconds_since_last_input() +def is_screen_locked() -> bool: + # TODO: Linux implementation (e.g. logind LockedHint / DBus screensaver) + return False + + if __name__ == "__main__": while True: sleep(1) - print(seconds_since_last_input()) + print(seconds_since_last_input(), is_screen_locked()) diff --git a/aw_watcher_afk/windows.py b/aw_watcher_afk/windows.py index a55f7cb..4a8feab 100644 --- a/aw_watcher_afk/windows.py +++ b/aw_watcher_afk/windows.py @@ -26,6 +26,11 @@ def _getTickCount() -> int: return c_GetTickCount() +def is_screen_locked() -> bool: + # TODO: Windows implementation (e.g. WTSQuerySessionInformation) + return False + + def seconds_since_last_input(): seconds_since_input = (_getTickCount() - _getLastInputTick()) / 1000 return seconds_since_input @@ -34,4 +39,4 @@ def seconds_since_last_input(): if __name__ == "__main__": while True: time.sleep(1) - print(seconds_since_last_input()) + print(seconds_since_last_input(), is_screen_locked())