Initial work on making Steam overlay accessible.

This commit is contained in:
Storm Dragon
2026-07-10 11:43:35 -04:00
parent 278a914630
commit 0858ec441a
4 changed files with 1335 additions and 4 deletions
+123 -2
View File
@@ -328,6 +328,118 @@ class EventManager:
def _isSteamNotificationEvent(self, event: Atspi.Event) -> bool:
return self._isLiveOrNotificationEvent(event)
def _isSteamOverlayNavigationEvent(self, event: Atspi.Event, app: Atspi.Accessible) -> bool:
if not self._isSteamApp(app):
return False
if not event.type.startswith((
"object:property-change:accessible-name",
"object:active-descendant-changed",
)):
return False
if not self._activeX11WindowIsSteamSelectionContext():
return False
return self._eventHasSteamQuickAccessContext(event)
def _eventHasSteamQuickAccessContext(self, event: Atspi.Event) -> bool:
for obj in (event.source, getattr(event, "any_data", None)):
if obj is None or isinstance(obj, (str, bytes, int, float, bool)):
continue
try:
if self._hasSteamQuickAccessMarker(obj):
return True
except Exception:
continue
return False
@staticmethod
def _hasSteamQuickAccessMarker(obj: Any) -> bool:
def hasMarker(candidate: Any) -> bool:
if candidate is None:
return False
name = AXObject.get_name(candidate) or ""
if name.startswith("QuickAccess_uid"):
return True
accessibleId = AXObject.get_attribute(candidate, "id") or ""
if accessibleId.startswith("quickaccess_"):
return True
className = AXObject.get_attribute(candidate, "class") or ""
return "quickaccess" in className.casefold()
return AXObject.find_ancestor_inclusive(obj, hasMarker) is not None
def _activeX11WindowIsSteamSelectionContext(self) -> bool:
window = input_event_manager.get_manager()._get_active_x11_window()
return self._x11WindowHasSteamGameClass(window) or self._x11WindowIsSteamBigPicture(window)
@classmethod
def _x11WindowHasSteamGameClass(cls, window: Any) -> bool:
if window is None:
return False
identifiers = [
cls._safeSteamWindowCall(window, "get_class_group_name"),
cls._safeSteamWindowCall(window, "get_class_instance_name"),
]
classGroup = cls._safeSteamWindowCall(window, "get_class_group")
if classGroup is not None:
identifiers.extend([
cls._safeSteamWindowCall(classGroup, "get_name"),
cls._safeSteamWindowCall(classGroup, "get_res_class"),
])
return any(cls._isSteamGameWindowIdentifier(identifier) for identifier in identifiers)
@classmethod
def _x11WindowIsSteamBigPicture(cls, window: Any) -> bool:
if window is None:
return False
name = cls._safeSteamWindowCall(window, "get_name")
if not isinstance(name, str) or name.strip() != "Steam Big Picture Mode":
return False
identifiers = [
cls._safeSteamWindowCall(window, "get_class_group_name"),
cls._safeSteamWindowCall(window, "get_class_instance_name"),
]
classGroup = cls._safeSteamWindowCall(window, "get_class_group")
if classGroup is not None:
identifiers.extend([
cls._safeSteamWindowCall(classGroup, "get_name"),
cls._safeSteamWindowCall(classGroup, "get_res_class"),
])
normalized = {
identifier.strip().casefold()
for identifier in identifiers
if isinstance(identifier, str)
}
return bool(normalized.intersection({"steam", "steamwebhelper"}))
@staticmethod
def _safeSteamWindowCall(window: Any, attrName: str):
attr = getattr(window, attrName, None)
if not callable(attr):
return None
try:
return attr()
except Exception:
return None
@staticmethod
def _isSteamGameWindowIdentifier(identifier: Any) -> bool:
if not isinstance(identifier, str):
return False
return identifier.strip().casefold().startswith("steam_app_")
def _isLiveOrNotificationObject(self, obj: Any) -> bool:
if obj is None or isinstance(obj, (str, bytes, int, float, bool)):
return False
@@ -559,18 +671,27 @@ class EventManager:
if self._inDeluge() and self._ignoreDuringDeluge(event):
return _ignore_with_reason("deluge", "event type during deluge")
isSteamOverlayNavigationEvent = self._isSteamOverlayNavigationEvent(event, app)
script = cthulhu_state.activeScript
if event.type.startswith('object:children-changed') \
or event.type.startswith('object:state-changed:sensitive'):
if not script:
return _ignore_with_reason("no-active-script", "no active script")
if script.app != app:
if isSteamOverlayNavigationEvent:
_log_allow("steam-overlay-navigation", "no active script")
else:
return _ignore_with_reason("no-active-script", "no active script")
elif script.app != app:
# Allow Steam notifications from inactive apps.
if self._isSteamApp(app) and self._isSteamNotificationEvent(event):
_log_allow("steam-notification", "inactive app notification")
elif isSteamOverlayNavigationEvent:
_log_allow("steam-overlay-navigation", "inactive app overlay navigation")
else:
return _ignore_with_reason("inactive-app", "event not from active app")
if isSteamOverlayNavigationEvent:
return _allow_with_reason("steam-overlay-navigation", "Steam overlay navigation event")
relevance = self._classifyRelevance(event, app)
if relevance == self.RELEVANCE_DROP:
return _ignore_with_reason("event-relevance-drop", "event is background churn")
@@ -29,15 +29,19 @@ __license__ = "LGPL"
import time
import re
from typing import Any
from gi.repository import GLib
from cthulhu import debug
from cthulhu import input_event_manager
from cthulhu import settings
from cthulhu import cthulhu_state
from cthulhu import settings_manager
from cthulhu import structural_navigation
from cthulhu.ax_object import AXObject
from cthulhu.ax_selection import AXSelection
from cthulhu.ax_text import AXText
from cthulhu.ax_utilities import AXUtilities
from cthulhu.ax_utilities_relation import AXUtilitiesRelation
from cthulhu.ax_utilities_role import AXUtilitiesRole
@@ -84,6 +88,10 @@ class Script(Chromium.Script):
self._lastSteamNotification = ("", 0.0)
self._steamPendingNotification = None
self._steamNotificationDelayMs = 500
self._lastSteamOverlaySelection = ("", 0.0)
self._steamActiveQuickAccessTab = ""
self._steamFriendsPanelActivationTarget = (None, "", 0.0)
self._steamFriendsPanelActivationTargetMaxAge = 10.0
self._steamRelativeTimePattern = re.compile(
r"^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$",
re.IGNORECASE
@@ -118,6 +126,9 @@ class Script(Chromium.Script):
self._presentSteamNotification(event.any_data)
return
if self._presentSteamFriendsPanelNavigation(event):
return True
if self._handleSteamVirtualizedListMutation(event):
return True
@@ -156,12 +167,24 @@ class Script(Chromium.Script):
return super().onFocusedChanged(event)
def onNameChanged(self, event):
"""Callback for object:property-change:accessible-name events."""
self._logSteamNavigationEvent("name-changed", event)
if self._presentSteamFriendsPanelNavigation(event):
return True
return super().onNameChanged(event)
def onTextInserted(self, event):
"""Callback for object:text-changed:insert accessibility events."""
if self._presentSteamLiveRegionText(event):
return
if self._presentSteamFriendsPanelNavigation(event):
return True
super().onTextInserted(event)
def onCaretMoved(self, event):
@@ -178,8 +201,18 @@ class Script(Chromium.Script):
"""Callback for object:selection-changed accessibility events."""
self._logSteamNavigationEvent("selection-changed", event)
if self._presentSteamOverlaySelection(event):
return True
return super().onSelectionChanged(event)
def onSelectedChanged(self, event):
"""Callback for object:state-changed:selected accessibility events."""
self._logSteamNavigationEvent("selected-changed", event)
if self._presentSteamOverlaySelection(event):
return True
return super().onSelectedChanged(event)
def onActiveDescendantChanged(self, event):
"""Callback for object:active-descendant-changed accessibility events."""
@@ -203,6 +236,445 @@ class Script(Chromium.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
def _presentSteamOverlaySelection(self, event) -> bool:
if not event:
return False
eventType = getattr(event, "type", "")
if not isinstance(eventType, str) or not (
eventType.startswith("object:selection-changed")
or eventType.startswith("object:state-changed:selected")
):
return False
if not self._activeX11WindowIsSteamSelectionContext():
return False
if not self._steamOverlaySelectionWasUserInitiated():
return False
obj = self._getSteamOverlaySelectionObject(event)
if not obj or not self._isSteamOverlaySelectionObject(obj):
return False
text = self._getSteamOverlaySelectionText(obj)
if not text:
return False
if AXUtilities.is_page_tab(obj):
self._steamActiveQuickAccessTab = text
self._clearSteamFriendsPanelActivationTarget()
if self._isDuplicateSteamOverlaySelection(text):
msg = f"STEAM: Suppressing duplicate overlay selection: {text}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
self.presentMessage(text, force=True, interrupt=True)
msg = f"STEAM: Presented overlay selection: {text}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
def _getSteamOverlaySelectionObject(self, event):
if event.type.startswith("object:state-changed:selected"):
if not event.detail1:
return None
return event.source
if not event.type.startswith("object:selection-changed"):
return None
for child in AXSelection.get_selected_children(event.source):
if child:
return child
anyData = event.any_data
if anyData is not None and not isinstance(anyData, (bool, int, float, str)):
return anyData
return event.source
def _isSteamOverlaySelectionObject(self, obj) -> bool:
if not obj:
return False
try:
if not self.utilities.inDocumentContent(obj):
return False
except Exception:
return False
if not self._hasSteamOverlaySelectionContext(obj):
return False
if not (
AXUtilities.is_page_tab(obj)
or AXUtilities.is_list_item(obj)
or AXUtilities.is_menu_item(obj)
or AXUtilities.is_button(obj)
or AXUtilities.is_push_button(obj)
):
return False
return bool(self._getSteamOverlaySelectionText(obj))
def _getSteamOverlaySelectionText(self, obj) -> str:
text = self.utilities.displayedText(obj) or AXObject.get_name(obj) or ""
text = self._normalizeSteamNotificationText(text)
if text:
return text
return self._getSteamQuickAccessTabLabel(obj)
def _getSteamQuickAccessTabLabel(self, obj) -> str:
if not AXUtilities.is_page_tab(obj):
return ""
if not self._hasSteamQuickAccessMarker(obj):
return ""
tabId = AXObject.get_attribute(obj, "id") or ""
quickAccessTabLabels = {
"quickaccess_tab_3": "Friends",
}
label = quickAccessTabLabels.get(tabId, "")
if label:
msg = f"STEAM: Inferred QuickAccess tab label: {label}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return label
def _presentSteamFriendsPanelNavigation(self, event) -> bool:
if not self._steamFriendsPanelNavigationIsExpected(event):
return False
obj = self._getSteamFriendsPanelNavigationObject(event)
if not obj or not self._isSteamFriendsPanelNavigationObject(obj):
return False
text = self._getSteamFriendsPanelNavigationText(obj)
if not text:
return False
self._rememberSteamFriendsPanelActivationTarget(obj, text)
if self._isDuplicateSteamOverlaySelection(text):
msg = f"STEAM: Suppressing duplicate Friends panel item: {text}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
self.presentMessage(text, force=True, interrupt=True)
msg = f"STEAM: Presented Friends panel item: {text}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
def _steamFriendsPanelNavigationIsExpected(self, event) -> bool:
if not event:
return False
eventType = getattr(event, "type", "")
if not isinstance(eventType, str) or not eventType.startswith((
"object:property-change:accessible-name",
"object:active-descendant-changed",
)):
return False
if getattr(self, "_steamActiveQuickAccessTab", "") != "Friends":
return False
if not self._activeX11WindowIsSteamSelectionContext():
return False
return self._steamOverlaySelectionWasUserInitiated()
@staticmethod
def _getSteamFriendsPanelNavigationObject(event):
anyData = getattr(event, "any_data", None)
if anyData is not None and not isinstance(anyData, (bool, int, float, str, bytes)):
return anyData
return getattr(event, "source", None)
def _isSteamFriendsPanelNavigationObject(self, obj) -> bool:
try:
if not self._hasSteamQuickAccessMarker(obj):
return False
except Exception:
return False
if AXUtilities.is_page_tab(obj) or AXUtilities.is_page_tab_list(obj):
return False
if self._isIgnoredSteamFriendsPanelObject(obj):
return False
return (
AXUtilities.is_list_item(obj)
or AXUtilities.is_button(obj)
or AXUtilities.is_push_button(obj)
or AXUtilities.is_table_row(obj)
)
def _isIgnoredSteamFriendsPanelObject(self, obj) -> bool:
def isIgnored(candidate) -> bool:
if candidate is None:
return False
className = AXObject.get_attribute(candidate, "class") or ""
accessibleId = AXObject.get_attribute(candidate, "id") or ""
searchable = f"{className} {accessibleId}".casefold()
ignoredMarkers = (
"addfriendbutton",
"chathistorycontainer",
"chatdialogs",
"chatwindow",
"chatentry",
"chatmessage",
"messagelist",
"messageinput",
"textarea",
)
return any(marker in searchable for marker in ignoredMarkers)
try:
return AXObject.find_ancestor_inclusive(obj, isIgnored) is not None
except Exception:
return False
def _getSteamFriendsPanelNavigationText(self, obj) -> str:
text = self._getSteamFriendsPanelReadableText(obj)
if self._isUsefulSteamFriendsPanelText(text):
return text
return ""
def _rememberSteamFriendsPanelActivationTarget(self, obj: Any, text: str) -> None:
self._steamFriendsPanelActivationTarget = (obj, text, time.monotonic())
def _clearSteamFriendsPanelActivationTarget(self) -> None:
self._steamFriendsPanelActivationTarget = (None, "", 0.0)
def _getSteamFriendsPanelActivationTarget(self) -> Any:
if getattr(self, "_steamActiveQuickAccessTab", "") != "Friends":
return None
if not self._activeX11WindowIsSteamSelectionContext():
return None
obj, text, timestamp = getattr(
self,
"_steamFriendsPanelActivationTarget",
(None, "", 0.0),
)
if not obj or not text:
return None
maxAge = getattr(self, "_steamFriendsPanelActivationTargetMaxAge", 10.0)
if time.monotonic() - timestamp > maxAge:
msg = f"STEAM: Clearing stale Friends panel activation target: {text}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._clearSteamFriendsPanelActivationTarget()
return None
try:
if AXObject.is_dead(obj):
self._clearSteamFriendsPanelActivationTarget()
return None
except Exception:
self._clearSteamFriendsPanelActivationTarget()
return None
if not self._isSteamFriendsPanelNavigationObject(obj):
self._clearSteamFriendsPanelActivationTarget()
return None
return obj
def _getSteamFriendsPanelReadableText(self, obj) -> str:
text = ""
try:
text = self.utilities.displayedText(obj) or AXObject.get_name(obj) or ""
except Exception:
try:
text = AXObject.get_name(obj) or ""
except Exception:
text = ""
text = self._normalizeSteamFriendsPanelText(text)
if text:
return text
try:
if AXObject.supports_text(obj):
text = AXText.get_all_text(obj) or ""
except Exception:
text = ""
return self._normalizeSteamFriendsPanelText(text)
def _normalizeSteamFriendsPanelText(self, text: str) -> str:
if not text:
return ""
text = text.replace(self.EMBEDDED_OBJECT_CHARACTER, " ")
text = text.replace("[OBJ]", " ")
return " ".join(text.split())
@staticmethod
def _isUsefulSteamFriendsPanelText(text: str) -> bool:
if not text:
return False
textLower = text.casefold()
if textLower in {"friends", "add friend", "unlabeled image"}:
return False
ignoredFragments = (
"loading older messages",
"hold to send a quick message",
"is typing a message",
)
if any(fragment in textLower for fragment in ignoredFragments):
return False
return not text.isdigit()
def _hasSteamQuickAccessMarker(self, obj) -> bool:
def hasMarker(candidate) -> bool:
if candidate is None:
return False
name = AXObject.get_name(candidate) or ""
if name.startswith("QuickAccess_uid"):
return True
accessibleId = AXObject.get_attribute(candidate, "id") or ""
if accessibleId.startswith("quickaccess_"):
return True
className = AXObject.get_attribute(candidate, "class") or ""
return "quickaccess" in className.casefold()
return AXObject.find_ancestor_inclusive(obj, hasMarker) is not None
def _hasSteamOverlaySelectionContext(self, obj) -> bool:
if self._hasSteamQuickAccessMarker(obj):
return True
return self._isSteamBigPictureDocumentObject(obj)
def _isSteamBigPictureDocumentObject(self, obj) -> bool:
try:
document = self.utilities.getTopLevelDocumentForObject(obj)
if not document:
document = self.utilities.getDocumentForObject(obj)
except Exception:
return False
if not document:
return False
try:
uri = self.utilities.documentFrameURI(document) or ""
except Exception:
uri = ""
if "steamloopback.host" in uri.casefold():
return True
name = AXObject.get_name(document) or ""
return name.strip() == "Steam Big Picture Mode"
def _steamOverlaySelectionWasUserInitiated(self) -> bool:
event = cthulhu_state.lastNonModifierKeyEvent or cthulhu_state.lastInputEvent
if event is None:
return False
modifiers = getattr(event, "modifiers", 0) or 0
if modifiers:
return False
key = getattr(event, "event_string", None) or getattr(event, "keyval_name", "")
return key in {"Left", "Right", "Up", "Down", "Return", "KP_Enter", "space", " "}
def _activeX11WindowIsSteamGame(self) -> bool:
window = input_event_manager.get_manager()._get_active_x11_window()
return self._x11WindowHasSteamGameClass(window)
def _activeX11WindowIsSteamSelectionContext(self) -> bool:
window = input_event_manager.get_manager()._get_active_x11_window()
return self._x11WindowHasSteamGameClass(window) or self._x11WindowIsSteamBigPicture(window)
@classmethod
def _x11WindowHasSteamGameClass(cls, window) -> bool:
if window is None:
return False
identifiers = [
cls._safeSteamWindowCall(window, "get_class_group_name"),
cls._safeSteamWindowCall(window, "get_class_instance_name"),
]
classGroup = cls._safeSteamWindowCall(window, "get_class_group")
if classGroup is not None:
identifiers.extend([
cls._safeSteamWindowCall(classGroup, "get_name"),
cls._safeSteamWindowCall(classGroup, "get_res_class"),
])
return any(cls._isSteamGameWindowIdentifier(identifier) for identifier in identifiers)
@classmethod
def _x11WindowIsSteamBigPicture(cls, window) -> bool:
if window is None:
return False
name = cls._safeSteamWindowCall(window, "get_name")
if not isinstance(name, str) or name.strip() != "Steam Big Picture Mode":
return False
identifiers = [
cls._safeSteamWindowCall(window, "get_class_group_name"),
cls._safeSteamWindowCall(window, "get_class_instance_name"),
]
classGroup = cls._safeSteamWindowCall(window, "get_class_group")
if classGroup is not None:
identifiers.extend([
cls._safeSteamWindowCall(classGroup, "get_name"),
cls._safeSteamWindowCall(classGroup, "get_res_class"),
])
normalized = {
identifier.strip().casefold()
for identifier in identifiers
if isinstance(identifier, str)
}
return bool(normalized.intersection({"steam", "steamwebhelper"}))
@staticmethod
def _safeSteamWindowCall(window: Any, attrName: str):
attr = getattr(window, attrName, None)
if not callable(attr):
return None
try:
return attr()
except Exception:
return None
@staticmethod
def _isSteamGameWindowIdentifier(identifier) -> bool:
if not isinstance(identifier, str):
return False
return identifier.strip().casefold().startswith("steam_app_")
def _isDuplicateSteamOverlaySelection(self, text: str) -> bool:
lastText, lastTime = getattr(self, "_lastSteamOverlaySelection", ("", 0.0))
now = time.monotonic()
if text == lastText and now - lastTime < 0.75:
return True
self._lastSteamOverlaySelection = (text, now)
return False
def _isSteamLoadingSpinnerCaretEvent(self, event) -> bool:
if not (event and event.type.startswith("object:text-caret-moved")):
return False
@@ -241,8 +713,15 @@ class Script(Chromium.Script):
if getattr(keyboardEvent, "modifiers", 0):
return False
obj = self._getClickableActivationTarget()
if not obj or not self.utilities.inDocumentContent(obj):
friendsPanelTarget = self._getSteamFriendsPanelActivationTarget()
obj = friendsPanelTarget or self._getClickableActivationTarget()
if not obj:
return False
try:
if not self.utilities.inDocumentContent(obj):
return False
except Exception:
return False
if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)):
@@ -255,16 +734,33 @@ class Script(Chromium.Script):
return False
if self._performClickableAction(obj):
self._logSteamFriendsPanelActivation(friendsPanelTarget)
return True
from cthulhu import ax_event_synthesizer
result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
if result:
self._logSteamFriendsPanelActivation(friendsPanelTarget)
self._restoreFocusAfterClick(obj)
return True
return False
def _logSteamFriendsPanelActivation(self, obj: Any) -> None:
if obj is None:
return
target, text, _timestamp = getattr(
self,
"_steamFriendsPanelActivationTarget",
(None, "", 0.0),
)
if obj is not target or not text:
return
msg = f"STEAM: Activated Friends panel item: {text}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _isSteamNotification(self, obj):
"""Detect if object is a Steam notification.