7 Commits
29 changed files with 2398 additions and 222 deletions
+15
View File
@@ -14,6 +14,16 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s
- `meson compile -C _build`
- `meson install -C _build`
## Runtime target and testing rules
- Make source changes in this repo, not in `~/.local/lib/python*/site-packages/cthulhu/`, unless the user explicitly asks for an installed-package hotfix.
- If you confirm the active import comes from `~/.local/...`, that does **not** mean you should edit there. It means you should update the repo and then run `./build-local.sh` to replace the installed copy for testing.
- Default test/apply workflow for local Cthulhu fixes:
- edit repo files
- run `./build-local.sh`
- reproduce/test against the refreshed `~/.local` install
- If repo and installed behavior differ, prefer rebuilding with `./build-local.sh` over patching the installed package directly.
- Treat direct edits under `~/.local/.../cthulhu/` as an exception path that requires explicit user approval.
## Coding guidelines
- **When modifying existing code:** follow the surrounding codes conventions.
- **When writing new code from scratch:** prefer
@@ -55,3 +65,8 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s
## Meson install reminder (important)
- If you add new Python modules under `src/cthulhu/`, update `src/cthulhu/meson.build` so they get installed (otherwise imports can fail after install).
- If you add a new plugin directory, update `src/cthulhu/plugins/meson.build` and add a `meson.build` in the plugin directory.
## Common Cthulhu agent mistakes
- Checking the import origin, seeing `~/.local/...`, and then editing the installed package instead of the repo.
- Forgetting that `./build-local.sh` is the normal way to apply repo changes into the installed copy for testing.
- Making repo fixes and then diagnosing the old installed copy without rebuilding.
+37 -2
View File
@@ -204,10 +204,21 @@ class AXHypertext:
@staticmethod
def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Optional[Atspi.Accessible]:
"""Attempts to correct for off-by-one brokenness in implementations"""
"""Returns the child at offset, correcting for broken hypertext offset mappings."""
if child := AXHypertext.get_child_at_offset(obj, offset):
return child
offset_in_parent = AXHypertext.get_character_offset_in_parent(child)
if offset_in_parent == offset:
return child
tokens = [
f"AXHypertext: Child at offset {offset} in",
obj,
"is",
child,
f"but reports offset {offset_in_parent}",
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if child_before := AXHypertext.get_child_at_offset(obj, offset - 1):
offset_in_parent = AXHypertext.get_character_offset_in_parent(child_before)
@@ -225,6 +236,30 @@ class AXHypertext:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return child_after
for i in range(AXHypertext._get_link_count(obj)):
link = AXHypertext._get_link_at_index(obj, i)
if link is None or AXHypertext.get_link_start_offset(link) != offset:
continue
try:
child = Atspi.Hyperlink.get_object(link, 0)
except GLib.GError as error:
msg = f"AXHypertext: Exception in find_child_at_offset: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
continue
if child is None:
continue
tokens = [
f"AXHypertext: Child at offset {offset} in",
obj,
"found via link enumeration:",
child,
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return child
return None
@staticmethod
+50 -5
View File
@@ -571,7 +571,7 @@
<property name="use_underline">True</property>
<property name="mnemonic_widget">timeFormatCombo</property>
<accessibility>
<relation type="label-for" target="availableProfilesComboBox1"/>
<relation type="label-for" target="timeFormatCombo"/>
</accessibility>
</object>
<packing>
@@ -588,7 +588,7 @@
<property name="use_underline">True</property>
<property name="mnemonic_widget">dateFormatCombo</property>
<accessibility>
<relation type="label-for" target="availableProfilesComboBox2"/>
<relation type="label-for" target="dateFormatCombo"/>
</accessibility>
</object>
<packing>
@@ -1053,6 +1053,51 @@
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkBox" id="soundSinkHBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">12</property>
<child>
<object class="GtkLabel" id="soundSinkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">0</property>
<property name="label" translatable="yes" comments="Translators: This is the label for a combo box where users can choose which audio backend Cthulhu should use.">Audio _backend:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">soundSinkCombo</property>
<accessibility>
<relation type="label-for" target="soundSinkCombo"/>
</accessibility>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="soundSinkCombo">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="changed" handler="soundSinkComboChanged" swapped="no"/>
<accessibility>
<relation type="labelled-by" target="soundSinkLabel"/>
</accessibility>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="soundThemeHBox">
<property name="visible">True</property>
@@ -1095,7 +1140,7 @@
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
<property name="position">1</property>
</packing>
</child>
<child>
@@ -1140,7 +1185,7 @@
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
<property name="position">2</property>
</packing>
</child>
</object>
@@ -1151,7 +1196,7 @@
<object class="GtkLabel" id="soundThemeTitleLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes" comments="Translators: This is the title of a section in the preferences dialog containing sound theme options.">Sound Theme</property>
<property name="label" translatable="yes" comments="Translators: This is the title of a section in the preferences dialog containing sound options.">Sound</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
+10 -2
View File
@@ -769,7 +769,7 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) ->
cthulhu_state.activeScript.presentationInterrupt()
cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
sound_theme_manager.getManager().playStopSound(wait=True)
sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1)
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
# Deactivate the event manager first so that it clears its queue and will not
@@ -893,7 +893,14 @@ def main() -> int:
debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initialized.", True)
script = cthulhu_state.activeScript
sound_theme_manager.getManager().playStartSound(wait=True)
sound_theme_manager.getManager().playStartSound(wait=False)
soundFailureReason = sound.getSoundSystemFailureReason()
if soundFailureReason:
debug.printMessage(
debug.LEVEL_INFO,
f"CTHULHU: Startup sound failed. Continuing without sound. Reason: {soundFailureReason}",
True
)
cthulhuApp.getSignalManager().emitSignal('start-application-completed')
if script:
window = script.utilities.activeWindow()
@@ -944,6 +951,7 @@ class Cthulhu(GObject.Object):
self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate
self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate
self.scriptManager: ScriptManager = script_manager.ScriptManager(self) # Directly instantiate
script_manager._manager = self.scriptManager
self.logger: logger.Logger = logger.Logger() # Directly instantiate
self.signalManager: SignalManager = signal_manager.SignalManager(self)
self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
+261 -5
View File
@@ -60,6 +60,7 @@ from . import cthulhu_gui_profile
from . import cthulhu_state
from . import settings
from . import settings_manager
from . import sound_sink
from . import input_event
from . import input_event_manager
from . import keybindings
@@ -71,6 +72,7 @@ from . import text_attribute_names
from . import sound_theme_manager
from . import script_manager
from .ax_object import AXObject
from .ax_utilities import AXUtilities
_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager
@@ -194,6 +196,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.savedPitch = None
self.savedRate = None
self.soundThemeCombo = None
self.soundSinkCombo = None
self.roleSoundPresentationCombo = None
self._isInitialSetup = False
self._updatingSpeechFamilies = False
@@ -202,6 +205,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.profilesCombo = None
self.profilesComboModel = None
self.startingProfileCombo = None
self._initialFocusSyncAttempts = 0
self._capturedKey = []
self.script = None
@@ -1506,6 +1510,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
#
if not serverInfo:
serverInfo = speech.getInfo()
if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default':
defaultFamily = None
voices = self.prefsDict.get("voices", {})
defaultVoice = voices.get(settings.DEFAULT_VOICE) if voices else None
if defaultVoice:
defaultFamily = acss.ACSS(defaultVoice).get(acss.ACSS.FAMILY)
resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily)
if resolved is not None:
serverInfo = resolved.getInfo()
valueSet = False
i = 0
@@ -1720,6 +1734,118 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
}
self.echoVoice['established'] = True
def _resolveSpeechDispatcherServerForFamily(self, family):
"""Returns a concrete Speech Dispatcher server matching the voice family."""
if not family or not self.speechServersChoices:
return None
name = family.get(speechserver.VoiceFamily.NAME)
language = family.get(speechserver.VoiceFamily.LANG)
dialect = family.get(speechserver.VoiceFamily.DIALECT)
variant = family.get(speechserver.VoiceFamily.VARIANT)
if not name:
return None
for server in self.speechServersChoices:
info = server.getInfo()
if not info or len(info) < 2 or info[1] == 'default':
continue
try:
families = server.getVoiceFamilies()
except Exception:
debug.printException(debug.LEVEL_FINEST)
continue
for candidate in families:
if candidate.get(speechserver.VoiceFamily.NAME) != name:
continue
if candidate.get(speechserver.VoiceFamily.LANG) != language:
continue
if candidate.get(speechserver.VoiceFamily.DIALECT) != dialect:
continue
if candidate.get(speechserver.VoiceFamily.VARIANT) != variant:
continue
tokens = [
"PREFERENCES DIALOG: Resolved voice family",
name,
"to speech server",
info,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return server
tokens = [
"PREFERENCES DIALOG: Could not resolve speech server for family",
family,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return None
def _getSpeechServerChoiceForSave(self):
"""Returns the server choice that should be persisted for speech settings."""
server = self.speechServersChoice
if not server or self.speechSystemsChoice != self._getSpeechDispatcherFactory():
return server
info = server.getInfo()
if not info or len(info) < 2 or info[1] != 'default':
return server
defaultFamily = None
if self.defaultVoice is not None:
defaultFamily = self.defaultVoice.get(acss.ACSS.FAMILY)
resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily)
return resolved or server
def _getEchoSpeechServerFamilyForSave(self):
"""Returns the most specific echo family to use for server resolution."""
family = None
if self.echoVoice is not None:
family = self.echoVoice.get(acss.ACSS.FAMILY)
if family:
return family
if self.defaultVoice is not None:
return self.defaultVoice.get(acss.ACSS.FAMILY)
return None
def _getEchoSpeechServerChoiceForSave(self):
"""Returns the echo speech server choice that should be persisted."""
server = self.echoSpeechServersChoice
if server is None:
return None
info = server.getInfo()
if not info or len(info) < 2 or info[1] != 'default':
return server
family = self._getEchoSpeechServerFamilyForSave()
resolved = self._resolveSpeechDispatcherServerForFamily(family)
if resolved is not None:
return resolved
speechServer = self._getSpeechServerChoiceForSave()
if speechServer is not None:
speechInfo = speechServer.getInfo()
if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default':
tokens = [
"PREFERENCES DIALOG: Falling back to main speech server for echo",
speechInfo,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return speechServer
return server
def _populateEchoSpeechFamilies(self, families):
"""Populate the echo family combobox from the provided families list."""
@@ -1873,6 +1999,23 @@ print(json.dumps(result))
self._setupEchoSpeechFamilies()
return
if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default':
family = self._getEchoSpeechServerFamilyForSave()
resolved = self._resolveSpeechDispatcherServerForFamily(family)
if resolved is None:
speechServer = self._getSpeechServerChoiceForSave()
if speechServer is not None:
speechInfo = speechServer.getInfo()
if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default':
resolved = speechServer
tokens = [
"PREFERENCES DIALOG: Reusing main speech server for echo",
speechInfo,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if resolved is not None:
serverInfo = resolved.getInfo()
valueSet = False
for i, server in enumerate(self.echoSpeechServersChoices):
info = server.getInfo()
@@ -2961,15 +3104,37 @@ print(json.dumps(result))
self.ocrCopyToClipboardCheckButton.set_active(copyToClipboard)
def _initSoundThemeState(self):
"""Initialize Sound Theme widgets with current settings."""
"""Initialize sound widgets with current settings."""
prefs = self.prefsDict
# Get widget references
self.soundSinkCombo = self.get_widget("soundSinkCombo")
self.soundThemeCombo = self.get_widget("soundThemeCombo")
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
self.soundSinkCombo.set_can_focus(False)
self.soundThemeCombo.set_can_focus(False)
self.roleSoundPresentationCombo.set_can_focus(False)
self._soundSinkChoices = [
(settings.SOUND_SINK_AUTO, guilabels.SOUND_BACKEND_AUTO),
(settings.SOUND_SINK_PIPEWIRE, guilabels.SOUND_BACKEND_PIPEWIRE),
(settings.SOUND_SINK_PULSE, guilabels.SOUND_BACKEND_PULSE),
(settings.SOUND_SINK_ALSA, guilabels.SOUND_BACKEND_ALSA),
]
self.soundSinkCombo.remove_all()
for _, label in self._soundSinkChoices:
self.soundSinkCombo.append_text(label)
runtimeSoundSink = cthulhu.cthulhuApp.settingsManager.getSetting("soundSink")
currentSink = sound_sink.normalize_sound_sink_choice(
prefs.get("soundSink", runtimeSoundSink if runtimeSoundSink is not None else settings.soundSink)
)
sinkIndex = 0
for index, (value, _) in enumerate(self._soundSinkChoices):
if value == currentSink:
sinkIndex = index
break
self.soundSinkCombo.set_active(sinkIndex)
# Populate sound theme combo box
themeManager = sound_theme_manager.getManager()
availableThemes = themeManager.getAvailableThemes()
@@ -3018,6 +3183,14 @@ print(json.dumps(result))
if activeText:
self.prefsDict["soundTheme"] = activeText
def soundSinkComboChanged(self, widget):
"""Signal handler for the sound backend combo box."""
activeIndex = widget.get_active()
if activeIndex < 0:
return
value = self._soundSinkChoices[activeIndex][0]
self.prefsDict["soundSink"] = value
def roleSoundPresentationComboChanged(self, widget):
"""Signal handler for the role sound presentation combo box."""
activeIndex = widget.get_active()
@@ -3148,6 +3321,68 @@ print(json.dumps(result))
cthulhuSetupWindow.set_title(title)
cthulhuSetupWindow.show()
self._initialFocusSyncAttempts = 0
GLib.idle_add(self._set_initial_window_state)
GLib.idle_add(self._set_initial_gtk_focus)
GLib.timeout_add(50, self._set_initial_window_state)
def _set_initial_gtk_focus(self):
"""Give GTK focus to a real preferences control as soon as the dialog appears."""
candidateIds = [
"generalDesktopButton",
"generalLaptopButton",
"availableProfilesComboBox1",
"speechSupportCheckButton",
"notebook",
]
cthulhuSetupWindow = self.get_widget("cthulhuSetupWindow")
for widgetId in candidateIds:
widget = self.get_widget(widgetId)
if not widget.get_visible() or not widget.get_sensitive():
continue
if not widget.get_can_focus():
continue
debug.printMessage(
debug.LEVEL_INFO,
f"PREFERENCES DIALOG: Setting initial GTK focus to {widgetId}",
True,
)
cthulhuSetupWindow.set_focus(widget)
widget.grab_focus()
return False
debug.printMessage(
debug.LEVEL_INFO,
"PREFERENCES DIALOG: No focusable initial GTK widget found",
True,
)
return False
def _set_initial_window_state(self):
"""Sync Cthulhu's active window to preferences without forcing dialog focus."""
self._initialFocusSyncAttempts += 1
activeWindow = AXUtilities.find_active_window()
if activeWindow is None:
return self._initialFocusSyncAttempts < 5
app = AXObject.get_application(activeWindow)
appName = (AXObject.get_name(app) or "").lower()
if appName != "cthulhu":
return self._initialFocusSyncAttempts < 5
debug.printTokens(
debug.LEVEL_INFO,
["PREFERENCES DIALOG: Syncing active window to", activeWindow],
True,
)
cthulhu.setActiveWindow(activeWindow, notifyScript=False)
tokens = ["PREFERENCES DIALOG: Synced initial window state to", activeWindow]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return False
def _initComboBox(self, combobox):
"""Initialize the given combo box to take a list of int/str pairs.
@@ -4641,6 +4876,11 @@ print(json.dumps(result))
if not self._isInitialSetup:
self.restoreSettings()
if self.soundSinkCombo is not None:
activeIndex = self.soundSinkCombo.get_active()
if activeIndex >= 0:
self.prefsDict["soundSink"] = self._soundSinkChoices[activeIndex][0]
enable = self.get_widget("speechSupportCheckButton").get_active()
self.prefsDict["enableSpeech"] = enable
@@ -4648,9 +4888,24 @@ print(json.dumps(result))
self.prefsDict["speechServerFactory"] = \
self.speechSystemsChoice.__name__
if self.speechServersChoice:
speechServerChoice = self._getSpeechServerChoiceForSave()
if speechServerChoice:
self.prefsDict["speechServerInfo"] = \
self.speechServersChoice.getInfo()
speechServerChoice.getInfo()
else:
activeSpeechInfo = speech.getInfo()
if activeSpeechInfo:
self.prefsDict["speechServerInfo"] = activeSpeechInfo
runtimeSpeechServerInfo = cthulhu.cthulhuApp.settingsManager.getSetting(
"speechServerInfo"
)
if runtimeSpeechServerInfo and not self.prefsDict.get("speechServerInfo"):
self.prefsDict["speechServerInfo"] = runtimeSpeechServerInfo
existingSpeechServerInfo = self.prefsDict.get("speechServerInfo")
if existingSpeechServerInfo:
self.prefsDict["speechServerInfo"] = existingSpeechServerInfo
if self.defaultVoice is not None:
self.prefsDict["voices"] = {
@@ -4684,8 +4939,9 @@ print(json.dumps(result))
self.echoVoice['established'] = True
self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice)
if self.echoSpeechServersChoice:
self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo()
echoSpeechServerChoice = self._getEchoSpeechServerChoiceForSave()
if echoSpeechServerChoice:
self.prefsDict["echoSpeechServerInfo"] = echoSpeechServerChoice.getInfo()
else:
self.prefsDict["echoSpeechServerInfo"] = None
+5
View File
@@ -47,6 +47,11 @@ __license__ = "LGPL"
#
locusOfFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
# A pending focused object from a Cthulhu-owned window that should be used
# for keyboard-event context until the queued focus event is processed.
#
pendingSelfHostedFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
# The currently active window.
#
activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
+141 -1
View File
@@ -70,7 +70,9 @@ class EventManager:
self._dequeueCount: int = 0
self._cmdlineCache: Dict[int, str] = {}
self._eventQueue: queue.Queue[Any] = queue.Queue(0)
self._prioritizedEvent: Optional[Atspi.Event] = None
self._gidleId: int = 0
self._prioritizedIdleId: int = 0
self._gidleLock: threading.Lock = threading.Lock()
self._gilSleepTime: float = 0.00001
self._synchronousToolkits: List[str] = ['VCL']
@@ -284,6 +286,12 @@ class EventManager:
if self._isDuplicateEvent(event):
return _ignore_with_reason("duplicate", "duplicate event")
if self._isSelfHostedFocusClearedEvent(event):
return _ignore_with_reason("self-hosted-focus-cleared", "self-hosted focused=false event")
if self._isRedundantSelfHostedPropertyEvent(event):
return _ignore_with_reason("self-hosted-redundant-property", "self-hosted redundant property event")
# Thunderbird spams us with these when a message list thread is expanded or collapsed.
if event.type.endswith('system') \
and AXObject.get_name(app).lower().startswith('thunderbird'):
@@ -596,6 +604,11 @@ class EventManager:
self._enqueueCount -= 1
return
if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e):
if debug.debugEventQueue:
self._enqueueCount -= 1
return
self._queuePrintln(e)
if self._inFlood() and self._prioritizeDuringFlood(e):
@@ -628,6 +641,73 @@ class EventManager:
if debug.debugEventQueue:
self._enqueueCount -= 1
def _isSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool:
"""Returns True if the event is a newly-focused event from Cthulhu."""
if not event.type.startswith("object:state-changed:focused") or not event.detail1:
return False
app = AXObject.get_application(event.source)
appName = (AXObject.get_name(app) or "").lower()
return appName == "cthulhu"
def _isSelfHostedFocusClearedEvent(self, event: Atspi.Event) -> bool:
"""Returns True if the event is a focus-lost event from Cthulhu."""
if not event.type.startswith("object:state-changed:focused") or event.detail1:
return False
app = AXObject.get_application(event.source)
appName = (AXObject.get_name(app) or "").lower()
return appName == "cthulhu"
def _isRedundantSelfHostedPropertyEvent(self, event: Atspi.Event) -> bool:
"""Returns True if the event is redundant prefs startup noise from Cthulhu."""
app = AXObject.get_application(event.source)
appName = (AXObject.get_name(app) or "").lower()
if appName != "cthulhu":
return False
if event.type.startswith("object:property-change:accessible-name"):
return AXUtilities.is_combo_box(event.source) or AXUtilities.is_table_cell(event.source)
if event.type.startswith("object:property-change:accessible-value"):
return AXObject.get_role(event.source) == Atspi.Role.SLIDER \
and event.source != cthulhu_state.locusOfFocus
return False
def _prioritizeSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool:
"""Schedules the latest focused-child event from Cthulhu ahead of the normal queue."""
if not self._isSelfHostedFocusedEvent(event):
return False
tokens = ["EVENT MANAGER: Prioritizing self-hosted focused event for", event.source]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
script = self._get_scriptForEvent(event)
if script is not None:
script.eventCache[event.type] = (event, time.time())
self._gidleLock.acquire()
try:
replaced = self._prioritizedEvent is not None
self._prioritizedEvent = event
cthulhu_state.pendingSelfHostedFocus = event.source
if not self._prioritizedIdleId:
self._prioritizedIdleId = GLib.idle_add(
self._dequeuePrioritizedEvent,
priority=GLib.PRIORITY_HIGH_IDLE,
)
finally:
self._gidleLock.release()
msg = f"EVENT MANAGER: Prioritized self-hosted focused event. Replaced pending event: {replaced}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
def _isNoFocus(self) -> bool:
if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript:
return False
@@ -645,6 +725,60 @@ class EventManager:
defaultScript.idleMessage()
return False
def _dequeuePrioritizedEvent(self) -> bool:
"""Handles prioritized focused events from Cthulhu-owned windows."""
self._gidleLock.acquire()
try:
event = self._prioritizedEvent
self._prioritizedEvent = None
self._prioritizedIdleId = 0
if event is not None and cthulhu_state.pendingSelfHostedFocus == event.source:
cthulhu_state.pendingSelfHostedFocus = None
finally:
self._gidleLock.release()
if event is None:
return False
tokens = ["EVENT MANAGER: Dequeued prioritized event", event]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
debugging = not debug.eventDebugFilter or debug.eventDebugFilter.match(event.type)
if debugging:
startTime = time.time()
msg = (
f"\nvvvvv PROCESS OBJECT EVENT {event.type} "
f"(prioritized) vvvvv"
)
debug.printMessage(debug.eventDebugLevel, msg, False)
try:
self._processObjectEvent(event)
if self._didSuspendEventsFor(event):
self._unsuspendEvents(event)
elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event):
self._unsuspendEvents(event, force=True)
except Exception:
debug.printException(debug.LEVEL_SEVERE)
if debugging:
msg = (
f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}"
f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n"
)
debug.printMessage(debug.eventDebugLevel, msg, False)
self._gidleLock.acquire()
try:
hasMore = self._prioritizedEvent is not None
if not hasMore and self._eventQueue.qsize() and not self._gidleId:
self._gidleId = GLib.idle_add(self._dequeue)
finally:
self._gidleLock.release()
return hasMore
def _dequeue(self) -> bool:
"""Handles all events destined for scripts. Called by the GTK
idle thread."""
@@ -657,7 +791,13 @@ class EventManager:
self._dequeueCount += 1
try:
event = self._eventQueue.get_nowait()
fromPriority = False
self._gidleLock.acquire()
try:
event = self._eventQueue.get_nowait()
finally:
self._gidleLock.release()
self._queuePrintln(event, isEnqueue=False)
inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
if isinstance(event, inputEvents):
+34 -6
View File
@@ -116,6 +116,13 @@ class FocusManager:
_log_tokens(["Focused object in", self._window, "is", result])
return result
def active_window_is_cthulhu(self) -> bool:
"""Returns True if the active window belongs to Cthulhu itself."""
app = AXObject.get_application(self._window)
appName = (AXObject.get_name(app) or "").lower()
return appName == "cthulhu"
def focus_and_window_are_unknown(self) -> bool:
"""Returns True if we have no knowledge about what is focused."""
@@ -350,18 +357,39 @@ class FocusManager:
self._window = frame
cthulhu_state.activeWindow = frame
contextObject = self._focus
if set_window_as_focus:
self.set_locus_of_focus(None, self._window, notify_script)
elif not (self.focus_is_active_window() or self.focus_is_in_active_window()):
_log_tokens(["Focus", self._focus, "is not in", self._window], stack=True)
# Don't update the focus to the active window if we can't get to the active window
# from the focused object. https://bugreports.qt.io/browse/QTBUG-130116
if not AXObject.has_broken_ancestry(self._focus):
self.set_locus_of_focus(None, self._window, notify_script=True)
if self.active_window_is_cthulhu():
_log_tokens(
["Skipping focused-object lookup and script activation for self-hosted window", self._window],
"self-window",
)
return
else:
focusedObject = self.find_focused_object()
if focusedObject is not None and focusedObject != self._window:
_log_tokens(["Using focused object", focusedObject, "from active window", self._window])
self.set_locus_of_focus(None, focusedObject, notify_script=True)
contextObject = focusedObject
elif self._focus is None:
_log_tokens(["No previous focus. Falling back to active window", self._window])
self.set_locus_of_focus(None, self._window, notify_script=True)
contextObject = self._window
# Don't update the focus to the active window if we can't get to the active window
# from the focused object. https://bugreports.qt.io/browse/QTBUG-130116
elif not AXObject.has_broken_ancestry(self._focus):
self.set_locus_of_focus(None, self._window, notify_script=True)
contextObject = self._window
app = _get_ax_utilities().get_application(self._focus)
self.app.scriptManager.activate_script_for_context(app, self._focus, "focus: active-window")
if contextObject is None:
contextObject = self._window
app = _get_ax_utilities().get_application(contextObject)
self.app.scriptManager.activate_script_for_context(app, contextObject, "focus: active-window")
@dbus_service.command
def toggle_presentation_mode(
+19 -2
View File
@@ -938,9 +938,26 @@ USE_STRUCTURAL_NAVIGATION = _("Enable _structural navigation")
# audio files that Cthulhu plays for various events.
SOUND_THEME = _("Sound _theme:")
# Translators: This is the label for a combo box in the preferences dialog
# where users can choose which audio backend Cthulhu should use.
SOUND_BACKEND = _("Audio _backend:")
# Translators: This is a sound backend option which lets Cthulhu choose a
# backend automatically.
SOUND_BACKEND_AUTO = _("Automatic")
# Translators: This is a sound backend option which forces PipeWire.
SOUND_BACKEND_PIPEWIRE = _("PipeWire")
# Translators: This is a sound backend option which forces PulseAudio.
SOUND_BACKEND_PULSE = _("PulseAudio")
# Translators: This is a sound backend option which forces ALSA.
SOUND_BACKEND_ALSA = _("ALSA")
# Translators: This is the title of a frame in the preferences dialog
# containing sound theme options.
SOUND_THEME_TITLE = _("Sound Theme")
# containing sound options.
SOUND_THEME_TITLE = _("Sound")
# Translators: This refers to the amount of information Cthulhu provides about a
# particular object that receives focus.
+42 -7
View File
@@ -277,6 +277,23 @@ class InputEventManager:
self._last_input_event = event
self._last_non_modifier_key_event = None
@staticmethod
def _get_top_level_window(obj: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]:
"""Returns the top-level window containing obj, if one can be found."""
if obj is None:
return None
if AXUtilities.is_frame(obj) or AXUtilities.is_window(obj) or AXUtilities.is_dialog_or_alert(obj):
return obj
return AXObject.find_ancestor(
obj,
lambda x: AXUtilities.is_frame(x)
or AXUtilities.is_window(x)
or AXUtilities.is_dialog_or_alert(x),
)
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
def process_keyboard_event(
@@ -306,6 +323,10 @@ class InputEventManager:
return False
manager = focus_manager.get_manager()
pendingFocus = cthulhu_state.pendingSelfHostedFocus
if pendingFocus is not None:
tokens = ["INPUT EVENT MANAGER: Using pending self-hosted focus for keyboard event:", pendingFocus]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if pressed:
window = manager.get_active_window()
if not AXUtilities.can_be_active_window(window, clear_cache=True):
@@ -316,13 +337,27 @@ class InputEventManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
manager.set_active_window(window)
else:
# One example: Brave's popup menus live in frames which lack the active state.
tokens = ["WARNING:", window, "cannot be active window. No alternative found."]
debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
window = None
manager.set_active_window(None, notify_script=True)
focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus())
if focus_window is not None:
window = focus_window
tokens = [
"INPUT EVENT MANAGER: Recovering active window from locus of focus:",
window,
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
manager.set_active_window(window)
else:
# One example: Brave's popup menus live in frames which lack the active
# state. Failing to revalidate the window on a key press is inconclusive;
# do not wipe out the last known window and focus state.
tokens = [
"WARNING:",
window,
"cannot be confirmed as active. No alternative found; preserving existing context.",
]
debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
event.set_window(window)
event.set_object(manager.get_locus_of_focus())
event.set_object(pendingFocus or manager.get_locus_of_focus())
event.set_script(script_manager.get_manager().get_active_script())
elif self.last_event_was_keyboard():
assert isinstance(self._last_input_event, input_event.KeyboardEvent)
@@ -331,7 +366,7 @@ class InputEventManager:
event.set_script(self._last_input_event.get_script())
else:
event.set_window(manager.get_active_window())
event.set_object(manager.get_locus_of_focus())
event.set_object(pendingFocus or manager.get_locus_of_focus())
event.set_script(script_manager.get_manager().get_active_script())
event._finalize_initialization()
+2
View File
@@ -86,6 +86,8 @@ cthulhu_python_sources = files([
'signal_manager.py',
'sleep_mode_manager.py',
'sound.py',
'sound_helper.py',
'sound_sink.py',
'sound_generator.py',
'sound_theme_manager.py',
'speech_and_verbosity_manager.py',
+8 -3
View File
@@ -41,6 +41,7 @@ else:
_gstreamerAvailable, args = Gst.init_check(None)
from . import debug
from . import sound_sink
class PiperAudioPlayer:
@@ -84,6 +85,7 @@ class PiperAudioPlayer:
try:
self._pipeline = Gst.Pipeline.new("piper-audio")
configuredSink = sound_sink.get_configured_sound_sink()
self._appsrc = Gst.ElementFactory.make("appsrc", "source")
if self._appsrc is None:
@@ -110,9 +112,9 @@ class PiperAudioPlayer:
debug.printMessage(debug.LEVEL_WARNING, msg, True)
return False
sink = Gst.ElementFactory.make("autoaudiosink", "sink")
sink, sinkName, sinkError = sound_sink.create_audio_sink("sink", configuredSink)
if sink is None:
msg = 'PIPER AUDIO: Failed to create autoaudiosink element'
msg = f'PIPER AUDIO: {sinkError or "Failed to create audio sink"}'
debug.printMessage(debug.LEVEL_WARNING, msg, True)
return False
@@ -141,7 +143,10 @@ class PiperAudioPlayer:
bus.connect("message", self._onMessage)
self._initialized = True
msg = 'PIPER AUDIO: Pipeline initialized successfully'
msg = (
f'PIPER AUDIO: Pipeline initialized successfully using '
f'{sinkName} (soundSink={configuredSink})'
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
+1 -1
View File
@@ -473,5 +473,5 @@ def get_manager() -> Optional[ScriptManager]:
if _manager is None:
from . import cthulhu
if cthulhu.cthulhuApp:
_manager = ScriptManager(cthulhu.cthulhuApp)
_manager = cthulhu.cthulhuApp.scriptManager
return _manager
@@ -34,6 +34,7 @@ __license__ = "LGPL"
import cthulhu.messages as messages
import cthulhu.scripts.default as default
import cthulhu.settings as settings
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
@@ -49,8 +50,15 @@ class Script(default.Script):
"""Callback for window:create accessibility events."""
allLabels = AXUtilities.find_all_labels(event.source)
texts = [self.utilities.displayedText(acc) for acc in allLabels]
text = f"{messages.NOTIFICATION} {' '.join(texts)}"
texts = []
for acc in allLabels:
text = self.utilities.displayedText(acc) or AXObject.get_name(acc)
if text:
texts.append(text)
text = messages.NOTIFICATION
if texts:
text = f"{text} {' '.join(texts)}"
voice = self.speechGenerator.voice(obj=event.source, string=text)
self.speakMessage(text, voice=voice)
@@ -203,19 +203,24 @@ class Script(Chromium.Script):
if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
return obj
def isNotificationRole(candidate):
return AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate)
ancestorNotification = AXObject.find_ancestor(obj, isNotificationRole)
if ancestorNotification:
return ancestorNotification
liveAttr = AXObject.get_attribute(obj, 'live')
containerLive = AXObject.get_attribute(obj, 'container-live')
if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']:
return obj
def isNotificationCandidate(candidate):
if AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate):
return True
def isLiveRegionCandidate(candidate):
candidateLive = AXObject.get_attribute(candidate, 'live')
candidateContainerLive = AXObject.get_attribute(candidate, 'container-live')
return candidateLive in ['assertive', 'polite'] or candidateContainerLive in ['assertive', 'polite']
return AXObject.find_ancestor(obj, isNotificationCandidate)
return AXObject.find_ancestor(obj, isLiveRegionCandidate)
def _presentSteamNotification(self, obj):
"""Speak and save the notification.
@@ -363,6 +368,27 @@ class Script(Chromium.Script):
return f"{baseText} {timestampText}"
return f"{baseText}. {timestampText}"
def _getSteamNotificationIdentity(self, obj):
if obj is None:
return None
try:
path = AXObject.get_path(obj)
except Exception:
path = None
if path is not None:
return tuple(path)
try:
return hash(obj)
except TypeError:
return id(obj)
def _combineSteamNotificationFragments(self, firstText, secondText):
combined = f"{firstText} {secondText}"
return self._normalizeSteamNotificationText(combined)
def _steamTextContains(self, text, other):
textNorm = self._normalizeSteamNotificationText(text).lower()
otherNorm = self._normalizeSteamNotificationText(other).lower()
@@ -376,6 +402,7 @@ class Script(Chromium.Script):
text = self._normalizeSteamNotificationText(text)
if not text:
return
sourceKey = self._getSteamNotificationIdentity(obj)
pending = self._steamPendingNotification
if self._isSteamRelativeTimestamp(text):
@@ -386,6 +413,7 @@ class Script(Chromium.Script):
pending["text"] = self._appendSteamTimestamp(pending["text"], text)
if obj:
pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer()
return
@@ -393,7 +421,8 @@ class Script(Chromium.Script):
"text": text,
"obj": obj,
"timerId": None,
"timestampOnly": True
"timestampOnly": True,
"sourceKey": sourceKey
}
self._resetSteamPendingTimer()
return
@@ -405,24 +434,39 @@ class Script(Chromium.Script):
pending["timestampOnly"] = False
if obj:
pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer()
return
if text == pendingText:
if obj:
pending["obj"] = obj
pending["sourceKey"] = sourceKey
return
if self._steamTextContains(text, pendingText):
pending["text"] = text
if obj:
pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer()
return
if self._steamTextContains(pendingText, text):
if obj:
pending["obj"] = obj
pending["sourceKey"] = sourceKey
return
# Steam often emits multi-line toasts as separate live-region fragments.
# Keep fragments from the same toast together instead of speaking the first
# line immediately and the complete toast later.
if sourceKey is not None and sourceKey == pending.get("sourceKey"):
pending["text"] = self._combineSteamNotificationFragments(pendingText, text)
if obj:
pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer()
return
self._flushSteamPendingNotification(fromTimer=False)
@@ -431,7 +475,8 @@ class Script(Chromium.Script):
"text": text,
"obj": obj,
"timerId": None,
"timestampOnly": False
"timestampOnly": False,
"sourceKey": sourceKey
}
self._resetSteamPendingTimer()
+8
View File
@@ -1925,6 +1925,14 @@ class Script(script.Script):
self.pointOfReference = {}
cthulhu.setActiveWindow(window)
app = AXObject.get_application(window)
appName = (AXObject.get_name(app) or "").lower()
if appName == "cthulhu":
msg = "DEFAULT: Self-hosted window activated. Waiting for focused child event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
if self.utilities.isKeyGrabEvent(event):
msg = "DEFAULT: Ignoring event. Likely from key grab."
debug.printMessage(debug.LEVEL_INFO, msg, True)
+44 -13
View File
@@ -1450,10 +1450,36 @@ class Script(default.Script):
return None
def _getClickableActivationTarget(self):
obj = cthulhu_state.locusOfFocus
if self.inFocusMode():
return obj
if not self.utilities.inDocumentContent(obj):
return obj
contextObj, _ = self.utilities.getCaretContext(searchIfNeeded=False)
if contextObj and self.utilities.inDocumentContent(contextObj):
return contextObj
return obj
def _performClickableAction(self, obj):
from cthulhu import ax_object
actionNames = ["click", "click-ancestor", "press", "jump", "open", "activate"]
for actionName in actionNames:
if not ax_object.AXObject.has_action(obj, actionName):
continue
if ax_object.AXObject.do_named_action(obj, actionName):
return True
return False
def _tryClickableActivation(self, keyboardEvent):
"""Try to activate clickable element - returns True if we should consume the event."""
obj = cthulhu_state.locusOfFocus
obj = self._getClickableActivationTarget()
if not obj or not self.utilities.inDocumentContent(obj):
return False
@@ -1474,31 +1500,36 @@ class Script(default.Script):
# First try the standard clickable detection
if self.utilities.isClickableElement(obj):
from cthulhu import ax_event_synthesizer
# Give immediate feedback that activation is starting
self.presentMessage("Activating...")
result = self._performClickableAction(obj)
if result:
self._presentDelayedMessage("Element activated", 50)
return True
from cthulhu import ax_event_synthesizer
result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
if result:
# Schedule success message after a brief delay
self._presentDelayedMessage("Element activated", 50)
# Try to restore focus to the clicked element after a brief delay
self._restoreFocusAfterClick(original_focus)
return True
# If that didn't work, try a more permissive approach for any element with click action
from cthulhu import ax_object
if ax_object.AXObject.has_action(obj, "click"):
from cthulhu import ax_event_synthesizer
# Give immediate feedback that activation is starting
if ax_object.AXObject.has_action(obj, "click") \
or ax_object.AXObject.has_action(obj, "click-ancestor"):
self.presentMessage("Activating...")
result = self._performClickableAction(obj)
if result:
self._presentDelayedMessage("Element activated", 50)
return True
from cthulhu import ax_event_synthesizer
result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
if result:
# Schedule success message after a brief delay
self._presentDelayedMessage("Element activated", 50)
# Try to restore focus to the clicked element after a brief delay
self._restoreFocusAfterClick(original_focus)
return True
return False
def _restoreFocusAfterClick(self, original_focus):
+34 -8
View File
@@ -618,8 +618,24 @@ class Utilities(script_utilities.Utilities):
return self.queryNonEmptyText(obj, False) is None
def isHidden(self, obj):
attrs = self.objectAttributes(obj, False)
return attrs.get('hidden', False)
hiddenValues = {"true", "1", "yes"}
hiddenStyles = {"none", "hidden", "collapse"}
current = obj
while current and self.inDocumentContent(current):
attrs = self.objectAttributes(current, False)
hidden = str(attrs.get("hidden", "")).lower()
display = str(attrs.get("display", "")).lower()
visibility = str(attrs.get("visibility", "")).lower()
if hidden in hiddenValues or display in hiddenStyles or visibility in hiddenStyles:
return True
parent = AXObject.get_parent(current)
if parent == current:
break
current = parent
return False
def _isOrIsIn(self, child, parent):
if not (child and parent):
@@ -4772,13 +4788,23 @@ class Utilities(script_utilities.Utilities):
startTime = time.time()
rv = None
if AXUtilities.is_focusable(obj):
tokens = ["WEB: Focusable object can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = True
if self.isHidden(obj):
tokens = ["WEB: Hidden object cannot have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = False
else:
tokens = ["WEB: Focusable object can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = True
elif AXUtilities.is_editable(obj):
tokens = ["WEB: Editable object can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = True
if self.isHidden(obj):
tokens = ["WEB: Hidden object cannot have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = False
else:
tokens = ["WEB: Editable object can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = True
elif AXUtilities.is_landmark(obj):
tokens = ["WEB: Landmark can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
+12
View File
@@ -85,6 +85,7 @@ userCustomizableSettings = [
"brailleLinkIndicator",
"enableSound",
"soundVolume",
"soundSink",
"playSoundForRole",
"playSoundForState",
"playSoundForPositionInSet",
@@ -333,9 +334,20 @@ brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE
ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH = "sound_and_speech"
ROLE_SOUND_PRESENTATION_SPEECH_ONLY = "speech_only"
ROLE_SOUND_PRESENTATION_SOUND_ONLY = "sound_only"
SOUND_SINK_AUTO = "auto"
SOUND_SINK_PIPEWIRE = "pipewire"
SOUND_SINK_PULSE = "pulse"
SOUND_SINK_ALSA = "alsa"
SOUND_SINK_VALUES = (
SOUND_SINK_AUTO,
SOUND_SINK_PIPEWIRE,
SOUND_SINK_PULSE,
SOUND_SINK_ALSA,
)
enableSound = True
soundVolume = 0.5
soundSink = SOUND_SINK_AUTO
playSoundForRole = False
playSoundForState = False
playSoundForPositionInSet = False
+498 -149
View File
@@ -31,9 +31,14 @@ __date__ = "$Date:$"
__copyright__ = "Copyright (c) 2016 Cthulhu Team"
__license__ = "LGPL"
import json
import os
import subprocess
import sys
import threading
from typing import Any, Optional, Tuple
import gi
from gi.repository import GLib
from typing import Optional, Any
try:
gi.require_version('Gst', '1.0')
@@ -41,138 +46,66 @@ try:
except Exception:
_gstreamerAvailable: bool = False
else:
_gstreamerAvailable, args = Gst.init_check(None)
_gstreamerAvailable, _args = Gst.init_check(None)
from . import debug
from . import settings
from . import sound_sink
from .sound_generator import Icon, Tone
_soundSystemFailureReason: Optional[str] = None
class _PendingResponse:
def __init__(self) -> None:
self.event = threading.Event()
self.response: Optional[dict[str, Any]] = None
class Player:
"""Plays Icons and Tones."""
"""Plays Icons and Tones through a persistent worker process."""
def __init__(self) -> None:
self._initialized: bool = False
self._source: Optional[Any] = None # Optional[Gst.Element]
self._sink: Optional[Any] = None # Optional[Gst.Element]
self._player: Optional[Any] = None # Optional[Gst.Element]
self._pipeline: Optional[Any] = None # Optional[Gst.Pipeline]
self._initialized = False
self._workerProcess: Optional[subprocess.Popen[str]] = None
self._workerSink: Optional[str] = None
self._workerRestartRequired = False
self._workerRestartReason: Optional[str] = None
self._workerLock = threading.RLock()
self._responseLock = threading.Lock()
self._pendingResponses: dict[int, _PendingResponse] = {}
self._nextRequestId = 1
if not _gstreamerAvailable:
msg = 'SOUND ERROR: Gstreamer is not available'
debug.printMessage(debug.LEVEL_INFO, msg, True)
debug.printMessage(debug.LEVEL_INFO, 'SOUND ERROR: Gstreamer is not available', True)
return
self.init()
@staticmethod
def _get_configured_volume() -> float:
"""Returns the configured sound volume with a safe fallback."""
def _onPlayerMessage(self, bus: Any, message: Any) -> None: # bus: Gst.Bus, message: Gst.Message
if message.type == Gst.MessageType.EOS:
self._player.set_state(Gst.State.NULL)
elif message.type == Gst.MessageType.ERROR:
self._player.set_state(Gst.State.NULL)
error, info = message.parse_error()
msg = f'SOUND ERROR: {error}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
try:
from . import cthulhu
if cthulhu.cthulhuApp is not None:
volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume')
return max(0.0, float(volume))
except Exception:
pass
def _onPipelineMessage(self, bus: Any, message: Any) -> None: # bus: Gst.Bus, message: Gst.Message
if message.type == Gst.MessageType.EOS:
self._pipeline.set_state(Gst.State.NULL)
elif message.type == Gst.MessageType.ERROR:
self._pipeline.set_state(Gst.State.NULL)
error, info = message.parse_error()
msg = f'SOUND ERROR: {error}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _onTimeout(self, element: Any) -> bool: # element: Gst.Element
element.set_state(Gst.State.NULL)
return False
def _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
"""Plays a sound icon, interrupting the current play first unless specified."""
if interrupt:
self._player.set_state(Gst.State.NULL)
self._player.set_property('uri', f'file://{icon.path}')
self._player.set_state(Gst.State.PLAYING)
def _playIconAndWait(self, icon: Icon, interrupt: bool = True, timeout_seconds: Optional[int] = 10) -> bool:
"""Plays a sound icon and waits for completion."""
if interrupt:
self._player.set_state(Gst.State.NULL)
self._player.set_property('uri', f'file://{icon.path}')
self._player.set_state(Gst.State.PLAYING)
bus = self._player.get_bus()
if not bus:
return False
if timeout_seconds is None:
timeout_ns = Gst.CLOCK_TIME_NONE
else:
timeout_ns = int(timeout_seconds * Gst.SECOND)
message = bus.timed_pop_filtered(
timeout_ns,
Gst.MessageType.EOS | Gst.MessageType.ERROR
)
if message and message.type == Gst.MessageType.ERROR:
error, info = message.parse_error()
msg = f'SOUND ERROR: {error}'
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._player.set_state(Gst.State.NULL)
return message is not None and message.type == Gst.MessageType.EOS
def _playTone(self, tone: Tone, interrupt: bool = True) -> None:
"""Plays a tone, interrupting the current play first unless specified."""
if interrupt:
self._pipeline.set_state(Gst.State.NULL)
self._source.set_property('volume', tone.volume)
self._source.set_property('freq', tone.frequency)
self._source.set_property('wave', tone.wave)
self._pipeline.set_state(Gst.State.PLAYING)
duration = int(1000 * tone.duration)
GLib.timeout_add(duration, self._onTimeout, self._pipeline)
return max(0.0, float(settings.soundVolume))
def init(self) -> None:
"""(Re)Initializes the Player."""
if self._initialized:
return
"""(Re)Initializes the persistent worker."""
if not _gstreamerAvailable:
return
self._player = Gst.ElementFactory.make('playbin', 'player')
if self._player is None:
msg = 'SOUND ERROR: Gstreamer is available, but player is None'
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
with self._workerLock:
if self._ensureWorkerLocked():
self._initialized = True
clearSoundSystemFailure()
bus = self._player.get_bus()
bus.add_signal_watch()
bus.connect("message", self._onPlayerMessage)
self._pipeline = Gst.Pipeline(name='cthulhu-pipeline')
bus = self._pipeline.get_bus()
bus.add_signal_watch()
bus.connect("message", self._onPipelineMessage)
self._source = Gst.ElementFactory.make('audiotestsrc', 'src')
self._sink = Gst.ElementFactory.make('autoaudiosink', 'output')
if self._source is None or self._sink is None:
return
self._pipeline.add(self._source)
self._pipeline.add(self._sink)
self._source.link(self._sink)
self._initialized = True
def play(self, item, interrupt=True):
def play(self, item: Any, interrupt: bool = True) -> None:
"""Plays a sound, interrupting the current play first unless specified."""
if isinstance(item, Icon):
@@ -180,56 +113,472 @@ class Player:
elif isinstance(item, Tone):
self._playTone(item, interrupt)
else:
tokens = ["SOUND ERROR:", item, "is not an Icon or Tone"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
debug.printTokens(debug.LEVEL_INFO, ["SOUND ERROR:", item, "is not an Icon or Tone"], True)
def playAndWait(self, item, interrupt=True, timeout_seconds=10):
def playAndWait(self, item: Any, interrupt: bool = True, timeout_seconds: int = 10) -> bool:
"""Plays a sound and blocks until completion or timeout."""
if not self._player:
if _gstreamerAvailable and not self._initialized:
self.init()
if not self._player:
return False
if isinstance(item, Icon):
return self._playIconAndWait(
item,
interrupt=interrupt,
timeout_seconds=timeout_seconds
)
return self._playIconAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
if isinstance(item, Tone):
return self._playToneAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
self.play(item, interrupt)
return False
def stop(self, element=None):
"""Stops play."""
def stop(self, _element: Any = None) -> None:
"""Stops current sound playback."""
if not _gstreamerAvailable:
return
self._sendWorkerCommand({"action": "stop"}, waitForResponse=False)
if element:
element.set_state(Gst.State.NULL)
return
if self._player:
self._player.set_state(Gst.State.NULL)
if self._pipeline:
self._pipeline.set_state(Gst.State.NULL)
def shutdown(self):
def shutdown(self) -> None:
"""Shuts down the sound utilities."""
global _gstreamerAvailable
if not _gstreamerAvailable:
return
self.stop()
self._initialized = False
_gstreamerAvailable = False
with self._workerLock:
self._stopWorkerLocked("Sound system shutdown")
self._initialized = False
def _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
if not icon.isValid():
return
success, reason = self._sendWorkerCommand(
{
"action": "play_file",
"path": icon.path,
"volume": self._get_configured_volume(),
"interrupt": interrupt,
},
waitForResponse=False,
)
if not success and reason:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
def _playIconAndWait(
self,
icon: Icon,
interrupt: bool = True,
timeout_seconds: Optional[int] = 10,
) -> bool:
if not icon.isValid():
return False
timeout = float((timeout_seconds or 10) + 2)
success, reason = self._sendWorkerCommand(
{
"action": "play_file",
"path": icon.path,
"volume": self._get_configured_volume(),
"interrupt": interrupt,
},
waitForResponse=True,
timeout=timeout,
)
if not success and reason:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
return success
def _playTone(self, tone: Tone, interrupt: bool = True) -> None:
success, reason = self._sendWorkerCommand(
self._buildToneCommand(tone, interrupt),
waitForResponse=False,
)
if not success and reason:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
def _playToneAndWait(
self,
tone: Tone,
interrupt: bool = True,
timeout_seconds: Optional[int] = 10,
) -> bool:
timeout = max(float(timeout_seconds or 10), float(tone.duration) + 2.0)
success, reason = self._sendWorkerCommand(
self._buildToneCommand(tone, interrupt),
waitForResponse=True,
timeout=timeout,
)
if not success and reason:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
return success
def _buildToneCommand(self, tone: Tone, interrupt: bool) -> dict[str, Any]:
return {
"action": "play_tone",
"duration": tone.duration,
"frequency": tone.frequency,
"volume": tone.volume,
"wave": tone.wave,
"interrupt": interrupt,
}
def _ensureWorkerLocked(self) -> bool:
configuredSink = sound_sink.get_configured_sound_sink()
if self._workerProcess is not None and self._workerProcess.poll() is None:
if self._workerRestartRequired:
reason = self._consumeWorkerRestartReason()
debug.printMessage(
debug.LEVEL_INFO,
f"SOUND: Restarting persistent worker after recovery request: {reason}",
True,
)
self._stopWorkerLocked(reason)
elif self._workerSink == configuredSink:
return True
else:
debug.printMessage(
debug.LEVEL_INFO,
f"SOUND: Restarting persistent worker for soundSink={configuredSink}",
True,
)
self._stopWorkerLocked("Sound sink changed")
return self._startWorkerLocked(configuredSink)
def _startWorkerLocked(self, configuredSink: str) -> bool:
environment = _buildSoundHelperEnvironment()
command = [
sys.executable,
"-m",
"cthulhu.sound_helper",
"--worker",
"--sound-sink",
configuredSink,
]
try:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=environment,
)
except Exception as error:
reason = f"Failed to start persistent sound worker: {error}"
disableSoundSystem(reason)
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
return False
if process.stdin is None or process.stdout is None or process.stderr is None:
try:
process.terminate()
except Exception:
pass
reason = "Persistent sound worker is missing stdio pipes"
disableSoundSystem(reason)
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
return False
self._workerProcess = process
self._workerSink = configuredSink
self._workerRestartRequired = False
self._workerRestartReason = None
self._startWorkerThreads(process)
success, reason = self._sendWorkerCommandLocked(
{"action": "ping"},
waitForResponse=True,
timeout=2.0,
allowRestart=False,
)
if not success:
self._stopWorkerLocked(reason or "Persistent sound worker failed to initialize")
disableSoundSystem(reason or "Persistent sound worker failed to initialize")
if reason:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
return False
debug.printMessage(
debug.LEVEL_INFO,
f"SOUND: Using persistent worker for icon playback (soundSink={configuredSink})",
True,
)
debug.printMessage(
debug.LEVEL_INFO,
f"SOUND: Using persistent worker for tone playback (soundSink={configuredSink})",
True,
)
return True
def _startWorkerThreads(self, process: subprocess.Popen[str]) -> None:
threading.Thread(
target=self._readWorkerStdout,
args=(process,),
daemon=True,
).start()
threading.Thread(
target=self._readWorkerStderr,
args=(process,),
daemon=True,
).start()
threading.Thread(
target=self._watchWorkerExit,
args=(process,),
daemon=True,
).start()
def _readWorkerStdout(self, process: subprocess.Popen[str]) -> None:
assert process.stdout is not None
for line in process.stdout:
line = line.strip()
if not line:
continue
try:
response = json.loads(line)
except Exception:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: Invalid worker response: {line}", True)
continue
requestId = response.get("id")
if requestId is None:
continue
with self._responseLock:
pending = self._pendingResponses.pop(int(requestId), None)
if pending is None:
continue
pending.response = response
pending.event.set()
def _readWorkerStderr(self, process: subprocess.Popen[str]) -> None:
assert process.stderr is not None
for line in process.stderr:
message = line.strip()
if message:
self._handleWorkerDiagnostic(message)
debug.printMessage(debug.LEVEL_INFO, f"SOUND WORKER: {message}", True)
def _watchWorkerExit(self, process: subprocess.Popen[str]) -> None:
returnCode = process.wait()
reason = _formatWorkerFailure(returnCode)
with self._workerLock:
if self._workerProcess is process:
self._workerProcess = None
self._workerSink = None
self._failPendingResponses(reason)
if returnCode != 0:
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
def _failPendingResponses(self, reason: str) -> None:
with self._responseLock:
pendingResponses = list(self._pendingResponses.values())
self._pendingResponses.clear()
for pending in pendingResponses:
pending.response = {"ok": False, "error": reason}
pending.event.set()
def _stopWorkerLocked(self, reason: str) -> None:
process = self._workerProcess
self._workerProcess = None
self._workerSink = None
self._workerRestartRequired = False
self._workerRestartReason = None
if process is None:
return
if process.poll() is None:
try:
assert process.stdin is not None
process.stdin.write(json.dumps({"action": "shutdown"}) + "\n")
process.stdin.flush()
process.wait(timeout=1.0)
except Exception:
try:
process.terminate()
process.wait(timeout=1.0)
except Exception:
try:
process.kill()
process.wait(timeout=1.0)
except Exception:
pass
self._failPendingResponses(reason)
def _sendWorkerCommand(
self,
command: dict[str, Any],
waitForResponse: bool,
timeout: float = 2.0,
) -> Tuple[bool, Optional[str]]:
with self._workerLock:
return self._sendWorkerCommandLocked(command, waitForResponse, timeout)
def _sendWorkerCommandLocked(
self,
command: dict[str, Any],
waitForResponse: bool,
timeout: float = 2.0,
allowRestart: bool = True,
) -> Tuple[bool, Optional[str]]:
if not self._ensureWorkerLocked():
return False, getSoundSystemFailureReason() or "Persistent sound worker is unavailable"
process = self._workerProcess
if process is None or process.stdin is None:
if allowRestart:
self._stopWorkerLocked("Worker pipes disappeared")
if self._ensureWorkerLocked():
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
return False, "Persistent sound worker stdin is unavailable"
requestId: Optional[int] = None
pending: Optional[_PendingResponse] = None
payload = dict(command)
if waitForResponse:
requestId = self._allocateRequestId()
pending = _PendingResponse()
payload["id"] = requestId
with self._responseLock:
self._pendingResponses[requestId] = pending
try:
process.stdin.write(json.dumps(payload) + "\n")
process.stdin.flush()
except Exception as error:
if requestId is not None:
with self._responseLock:
self._pendingResponses.pop(requestId, None)
if allowRestart:
self._stopWorkerLocked(f"Worker write failed: {error}")
if self._ensureWorkerLocked():
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
return False, f"Worker write failed: {error}"
if not waitForResponse or pending is None:
return True, None
if pending.event.wait(timeout):
response = pending.response or {"ok": False, "error": "No worker response"}
if not bool(response.get("ok")):
self._maybeMarkWorkerRestartRequired(response.get("error"))
return bool(response.get("ok")), response.get("error")
with self._responseLock:
self._pendingResponses.pop(requestId, None)
if allowRestart and (self._workerProcess is None or self._workerProcess.poll() is not None):
self._stopWorkerLocked("Worker exited while waiting for response")
if self._ensureWorkerLocked():
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
reason = f"Persistent sound worker timed out after {timeout:.1f} seconds"
self._markWorkerRestartRequired(reason)
return False, reason
def _allocateRequestId(self) -> int:
with self._responseLock:
requestId = self._nextRequestId
self._nextRequestId += 1
return requestId
def _handleWorkerDiagnostic(self, message: str) -> None:
normalizedMessage = str(message).strip()
if not normalizedMessage.startswith("RECOVERY REQUIRED:"):
return
reason = normalizedMessage.split(":", 1)[1].strip() or "Worker requested recovery"
self._markWorkerRestartRequired(reason)
def _markWorkerRestartRequired(self, reason: Optional[str]) -> None:
normalizedReason = str(reason or "").strip() or "Worker requested recovery"
with self._workerLock:
self._workerRestartRequired = True
self._workerRestartReason = normalizedReason
def _consumeWorkerRestartReason(self) -> str:
reason = self._workerRestartReason or "Worker requested recovery"
self._workerRestartRequired = False
self._workerRestartReason = None
return reason
def _maybeMarkWorkerRestartRequired(self, reason: Optional[str]) -> None:
normalizedReason = str(reason or "").strip().lower()
if normalizedReason in {"", "interrupted", "stopped", "shutdown", "stdin closed"}:
return
self._markWorkerRestartRequired(reason)
def disableSoundSystem(reason: str) -> None:
global _soundSystemFailureReason
if _soundSystemFailureReason == reason:
return
_soundSystemFailureReason = reason
debug.printMessage(debug.LEVEL_INFO, f"SOUND: Disabling sound system. Reason: {reason}", True)
def getSoundSystemFailureReason() -> Optional[str]:
return _soundSystemFailureReason
def isSoundSystemAvailable() -> bool:
return _soundSystemFailureReason is None
def clearSoundSystemFailure() -> None:
global _soundSystemFailureReason
_soundSystemFailureReason = None
def _buildSoundHelperEnvironment() -> dict[str, str]:
pythonPathEntries = []
for path in sys.path:
if not path:
path = os.getcwd()
if os.path.isdir(path) and path not in pythonPathEntries:
pythonPathEntries.append(path)
environment = os.environ.copy()
if pythonPathEntries:
environment["PYTHONPATH"] = os.pathsep.join(pythonPathEntries)
return environment
def _formatWorkerFailure(returnCode: int) -> str:
if returnCode < 0:
return f"Sound worker exited via signal {-returnCode}"
return f"Sound worker exited with status {returnCode}"
_player = Player()
def getPlayer():
def getPlayer() -> Player:
return _player
def play(item: Any, interrupt: bool = True) -> None:
_player.play(item, interrupt)
def playIconSafely(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]:
if not icon.isValid():
return False, f"Invalid sound icon: {icon.path}"
return _player._sendWorkerCommand(
{
"action": "play_file",
"path": icon.path,
"volume": Player._get_configured_volume(),
"interrupt": True,
},
waitForResponse=True,
timeout=max(0.1, float(timeoutSeconds)),
)
+443
View File
@@ -0,0 +1,443 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
"""Persistent sound helper used to isolate theme and tone playback crashes."""
import argparse
from collections import deque
import json
import pathlib
import sys
import threading
from typing import Any, Optional
import gi
gi.require_version('GLib', '2.0')
gi.require_version('Gst', '1.0')
from gi.repository import GLib, Gst
from . import settings
from . import sound_sink
def _clamp_volume(volume: Any) -> float:
try:
return max(0.0, float(volume))
except Exception:
return 1.0
def _write_json_line(payload: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(payload) + "\n")
sys.stdout.flush()
def _report_recovery_required(message: str) -> None:
print(f"RECOVERY REQUIRED: {message}", file=sys.stderr, flush=True)
def _create_file_player(
playerId: str,
soundSink: Optional[str] = None,
) -> tuple[Optional[Any], Optional[str], Optional[str]]:
player = Gst.ElementFactory.make("playbin", playerId)
if player is None:
return None, None, "Failed to create playbin for file playback"
configuredSink = sound_sink.normalize_sound_sink_choice(soundSink)
if configuredSink == settings.SOUND_SINK_AUTO:
return player, "playbin-default", None
audioSink, sinkName, sinkError = sound_sink.create_audio_sink(
f"{playerId}-output",
configuredSink,
)
if audioSink is None:
return None, sinkName, sinkError or f"Failed to create audio sink for {configuredSink}"
player.set_property("audio-sink", audioSink)
return player, sinkName or configuredSink, None
class SoundWorker:
"""Runs a long-lived GStreamer worker for icon and tone playback."""
def __init__(self, soundSink: Optional[str] = None) -> None:
available, _args = Gst.init_check(None)
if not available:
raise RuntimeError("GStreamer is not available")
self._loop = GLib.MainLoop()
self._queue: deque[dict[str, Any]] = deque()
self._currentCommand: Optional[dict[str, Any]] = None
self._toneTimeoutId = 0
self._filePlayer, fileSinkName, fileSinkError = _create_file_player(
"cthulhu-sound-worker-file",
soundSink,
)
if self._filePlayer is None:
raise RuntimeError(fileSinkError or "Failed to create file playback player")
fileBus = self._filePlayer.get_bus()
if fileBus is None:
raise RuntimeError("No bus available for file playback player")
fileBus.add_signal_watch()
fileBus.connect("message", self._onFileMessage)
self._tonePipeline = Gst.Pipeline.new('cthulhu-sound-worker-tone')
if self._tonePipeline is None:
raise RuntimeError("Failed to create tone pipeline")
self._toneSource = Gst.ElementFactory.make('audiotestsrc', 'cthulhu-sound-worker-source')
self._toneVolume = Gst.ElementFactory.make('volume', 'cthulhu-sound-worker-volume')
toneSink, toneSinkName, toneSinkError = sound_sink.create_audio_sink(
'cthulhu-sound-worker-tone-output',
soundSink
)
if self._toneSource is None or toneSink is None:
raise RuntimeError(toneSinkError or "Failed to create tone playback pipeline")
self._toneSink = toneSink
self._tonePipeline.add(self._toneSource)
if self._toneVolume is not None:
self._tonePipeline.add(self._toneVolume)
self._tonePipeline.add(self._toneSink)
if self._toneVolume is not None:
if not self._toneSource.link(self._toneVolume):
raise RuntimeError("Failed to link tone source to volume")
if not self._toneVolume.link(self._toneSink):
raise RuntimeError("Failed to link tone volume to sink")
elif not self._toneSource.link(self._toneSink):
raise RuntimeError("Failed to link tone source to sink")
toneBus = self._tonePipeline.get_bus()
if toneBus is None:
raise RuntimeError("No bus available for tone pipeline")
toneBus.add_signal_watch()
toneBus.connect("message", self._onToneMessage)
self._fileSinkName = fileSinkName or "unknown"
self._toneSinkName = toneSinkName or "unknown"
def run(self) -> int:
self._report_worker_ready()
readerThread = threading.Thread(target=self._read_commands, daemon=True)
readerThread.start()
try:
self._loop.run()
return 0
finally:
self._clear_tone_timeout()
self._filePlayer.set_state(Gst.State.NULL)
self._tonePipeline.set_state(Gst.State.NULL)
def _report_worker_ready(self) -> None:
print(
f"Persistent sound worker ready: file={self._fileSinkName} tone={self._toneSinkName}",
file=sys.stderr,
flush=True,
)
def _read_commands(self) -> None:
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
command = json.loads(line)
except Exception as error:
print(f"Invalid worker command: {error}", file=sys.stderr, flush=True)
continue
GLib.idle_add(self._handle_command, command)
finally:
GLib.idle_add(self._quit_from_stdin_close)
def _quit_from_stdin_close(self) -> bool:
self._interrupt_current("stdin closed")
self._interrupt_queued("stdin closed")
self._loop.quit()
return False
def _handle_command(self, command: dict[str, Any]) -> bool:
action = str(command.get("action", "")).strip().lower()
requestId = command.get("id")
if action == "ping":
self._respond(requestId, True)
return False
if action == "shutdown":
self._interrupt_current("shutdown")
self._interrupt_queued("shutdown")
self._respond(requestId, True)
self._loop.quit()
return False
if action == "stop":
self._interrupt_current("stopped")
self._interrupt_queued("stopped")
self._respond(requestId, True)
return False
if action not in {"play_file", "play_tone"}:
self._respond(requestId, False, f"Unknown worker action: {action}")
return False
interrupt = bool(command.get("interrupt", True))
if interrupt:
self._interrupt_current("interrupted")
self._interrupt_queued("interrupted")
elif self._currentCommand is not None:
self._queue.append(command)
return False
if self._currentCommand is None:
self._start_command(command)
else:
self._queue.appendleft(command)
return False
def _start_command(self, command: dict[str, Any]) -> None:
action = command["action"]
if action == "play_file":
self._start_file_command(command)
return
if action == "play_tone":
self._start_tone_command(command)
return
self._respond(command.get("id"), False, f"Unsupported worker action: {action}")
self._start_next_command()
def _start_file_command(self, command: dict[str, Any]) -> None:
soundPath = pathlib.Path(str(command.get("path", ""))).expanduser()
if not soundPath.is_file():
self._respond(command.get("id"), False, f"Missing sound file: {soundPath}")
self._start_next_command()
return
self._clear_tone_timeout()
self._tonePipeline.set_state(Gst.State.NULL)
self._filePlayer.set_state(Gst.State.NULL)
self._currentCommand = command
self._filePlayer.set_property("uri", soundPath.resolve().as_uri())
self._filePlayer.set_property("volume", _clamp_volume(command.get("volume", 1.0)))
stateChange = self._filePlayer.set_state(Gst.State.PLAYING)
if stateChange == Gst.StateChangeReturn.FAILURE:
self._currentCommand = None
reason = f"Failed to start playback for {soundPath}"
_report_recovery_required(reason)
self._respond(command.get("id"), False, reason)
self._start_next_command()
def _start_tone_command(self, command: dict[str, Any]) -> None:
self._filePlayer.set_state(Gst.State.NULL)
self._tonePipeline.set_state(Gst.State.NULL)
self._clear_tone_timeout()
try:
durationSeconds = max(0.0, float(command.get("duration", 0.0)))
frequency = max(0, min(20000, int(command.get("frequency", 0))))
wave = int(command.get("wave", 0))
except Exception as error:
self._respond(command.get("id"), False, f"Invalid tone request: {error}")
self._start_next_command()
return
self._currentCommand = command
if self._toneVolume is not None:
self._toneVolume.set_property('volume', _clamp_volume(command.get("volume", 1.0)))
self._toneSource.set_property('volume', 1.0)
else:
self._toneSource.set_property('volume', _clamp_volume(command.get("volume", 1.0)))
self._toneSource.set_property('freq', frequency)
self._toneSource.set_property('wave', wave)
stateChange = self._tonePipeline.set_state(Gst.State.PLAYING)
if stateChange == Gst.StateChangeReturn.FAILURE:
self._currentCommand = None
reason = "Failed to start tone playback"
_report_recovery_required(reason)
self._respond(command.get("id"), False, reason)
self._start_next_command()
return
durationMs = max(1, int(durationSeconds * 1000))
self._toneTimeoutId = GLib.timeout_add(durationMs, self._finish_tone_playback)
def _finish_tone_playback(self) -> bool:
self._toneTimeoutId = 0
self._tonePipeline.set_state(Gst.State.NULL)
self._finish_current(True)
return False
def _clear_tone_timeout(self) -> None:
if self._toneTimeoutId:
GLib.source_remove(self._toneTimeoutId)
self._toneTimeoutId = 0
def _interrupt_current(self, reason: str) -> None:
if self._currentCommand is None:
self._filePlayer.set_state(Gst.State.NULL)
self._tonePipeline.set_state(Gst.State.NULL)
self._clear_tone_timeout()
return
current = self._currentCommand
self._currentCommand = None
self._filePlayer.set_state(Gst.State.NULL)
self._tonePipeline.set_state(Gst.State.NULL)
self._clear_tone_timeout()
self._respond(current.get("id"), False, reason)
def _interrupt_queued(self, reason: str) -> None:
while self._queue:
queued = self._queue.popleft()
self._respond(queued.get("id"), False, reason)
def _finish_current(self, success: bool, error: Optional[str] = None) -> None:
current = self._currentCommand
self._currentCommand = None
if current is not None:
self._respond(current.get("id"), success, error)
self._start_next_command()
def _start_next_command(self) -> None:
if self._currentCommand is not None or not self._queue:
return
self._start_command(self._queue.popleft())
def _respond(self, requestId: Any, success: bool, error: Optional[str] = None) -> None:
if requestId is None:
return
payload: dict[str, Any] = {
"id": requestId,
"ok": bool(success),
}
if error:
payload["error"] = str(error)
_write_json_line(payload)
def _onFileMessage(self, _bus: Any, message: Any) -> None:
if self._currentCommand is None or self._currentCommand.get("action") != "play_file":
return
if message.type == Gst.MessageType.EOS:
self._filePlayer.set_state(Gst.State.NULL)
self._finish_current(True)
return
if message.type == Gst.MessageType.ERROR:
self._filePlayer.set_state(Gst.State.NULL)
error, _info = message.parse_error()
_report_recovery_required(str(error))
print(str(error), file=sys.stderr, flush=True)
self._finish_current(False, str(error))
def _onToneMessage(self, _bus: Any, message: Any) -> None:
if self._currentCommand is None or self._currentCommand.get("action") != "play_tone":
return
if message.type != Gst.MessageType.ERROR:
return
self._tonePipeline.set_state(Gst.State.NULL)
self._clear_tone_timeout()
error, _info = message.parse_error()
_report_recovery_required(str(error))
print(str(error), file=sys.stderr, flush=True)
self._finish_current(False, str(error))
def play_file_once(
soundPath: str,
timeoutSeconds: int = 10,
soundSink: Optional[str] = None,
volume: float = 1.0,
) -> int:
available, _args = Gst.init_check(None)
if not available:
print("GStreamer is not available", file=sys.stderr)
return 1
player, sinkName, sinkError = _create_file_player(
"cthulhu-sound-helper-once",
soundSink,
)
if player is None:
print(sinkError or "Failed to create one-shot file playback player", file=sys.stderr)
return 1
player.set_property("uri", pathlib.Path(soundPath).resolve().as_uri())
player.set_property("volume", _clamp_volume(volume))
player.set_state(Gst.State.PLAYING)
bus = player.get_bus()
if bus is None:
player.set_state(Gst.State.NULL)
print("No bus available for one-shot playback", file=sys.stderr)
return 1
timeoutNs = int(timeoutSeconds * Gst.SECOND)
try:
message = bus.timed_pop_filtered(
timeoutNs,
Gst.MessageType.EOS | Gst.MessageType.ERROR
)
if message is None:
print(
f"Sound helper timed out after {timeoutSeconds} seconds on sink {sinkName}",
file=sys.stderr
)
return 1
if message.type == Gst.MessageType.ERROR:
error, _info = message.parse_error()
print(str(error), file=sys.stderr)
return 1
return 0
finally:
player.set_state(Gst.State.NULL)
def main() -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--worker", action="store_true")
parser.add_argument("--play-file")
parser.add_argument("--sound-sink")
parser.add_argument("--volume", type=float, default=1.0)
parser.add_argument("--timeout-seconds", type=int, default=10)
args, _unknown = parser.parse_known_args()
if args.worker:
worker = SoundWorker(args.sound_sink)
return worker.run()
if args.play_file:
return play_file_once(
args.play_file,
args.timeout_seconds,
args.sound_sink,
args.volume,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
"""Utilities for selecting GStreamer audio sinks."""
from __future__ import annotations
from typing import Any, Optional, Tuple
import gi
try:
gi.require_version('Gst', '1.0')
from gi.repository import Gst
except Exception:
_gstreamerAvailable: bool = False
else:
_gstreamerAvailable, _args = Gst.init_check(None)
from . import settings
_SINK_ELEMENT_BY_SETTING = {
settings.SOUND_SINK_PIPEWIRE: "pipewiresink",
settings.SOUND_SINK_PULSE: "pulsesink",
settings.SOUND_SINK_ALSA: "alsasink",
}
_AUTO_SINK_ELEMENTS = [
"autoaudiosink",
"pipewiresink",
"pulsesink",
"alsasink",
]
def normalize_sound_sink_choice(soundSink: Optional[str]) -> str:
"""Return a valid sound sink setting value."""
if soundSink is None:
return settings.SOUND_SINK_AUTO
choice = str(soundSink).strip().lower()
if choice in settings.SOUND_SINK_VALUES:
return choice
return settings.SOUND_SINK_AUTO
def get_configured_sound_sink() -> str:
"""Return the configured sound sink, falling back to defaults."""
try:
from . import cthulhu
if cthulhu.cthulhuApp is not None:
configured = cthulhu.cthulhuApp.settingsManager.getSetting("soundSink")
return normalize_sound_sink_choice(configured)
except Exception:
pass
return normalize_sound_sink_choice(getattr(settings, "soundSink", settings.SOUND_SINK_AUTO))
def _get_sink_element_candidates(soundSink: str) -> list[str]:
if soundSink == settings.SOUND_SINK_AUTO:
return list(_AUTO_SINK_ELEMENTS)
elementName = _SINK_ELEMENT_BY_SETTING.get(soundSink)
if elementName:
return [elementName]
return list(_AUTO_SINK_ELEMENTS)
def create_audio_sink(
sinkId: str,
soundSink: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
"""Create a configured GStreamer audio sink."""
if not _gstreamerAvailable:
return None, None, "GStreamer is not available"
configuredSink = normalize_sound_sink_choice(soundSink or get_configured_sound_sink())
triedCandidates = []
for elementName in _get_sink_element_candidates(configuredSink):
factory = Gst.ElementFactory.find(elementName)
if factory is None:
triedCandidates.append(f"{elementName} (unavailable)")
continue
sink = Gst.ElementFactory.make(elementName, sinkId)
if sink is None:
triedCandidates.append(f"{elementName} (failed to create)")
continue
return sink, elementName, None
triedText = ", ".join(triedCandidates) if triedCandidates else "none"
reason = (
f"No usable audio sink for soundSink={configuredSink}. "
f"Tried: {triedText}"
)
return None, None, reason
+21 -9
View File
@@ -317,7 +317,7 @@ class SoundThemeManager:
return None
def _playThemeSound(self, soundName, interrupt=True, wait=False,
requireSoundSetting=False):
requireSoundSetting=False, timeoutSeconds=10):
"""Play a themed sound with optional gating and blocking.
Args:
@@ -349,9 +349,17 @@ class SoundThemeManager:
try:
icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
if icon.isValid():
if wait:
success, reason = sound.playIconSafely(icon, timeoutSeconds=timeoutSeconds)
if not success:
failureReason = reason or f"Failed to play sound '{soundName}'"
msg = (
"SOUND THEME: Failed to play helper-isolated sound "
f"'{soundName}'. Continuing with sound enabled. Reason: {failureReason}"
)
debug.printMessage(debug.LEVEL_INFO, msg, True)
return success
player = sound.getPlayer()
if wait and hasattr(player, "playAndWait"):
return player.playAndWait(icon, interrupt=interrupt)
player.play(icon, interrupt=interrupt)
return True
except Exception as e:
@@ -360,7 +368,7 @@ class SoundThemeManager:
return False
def playSound(self, soundName, interrupt=True, wait=False):
def playSound(self, soundName, interrupt=True, wait=False, timeoutSeconds=10):
"""Play a sound from the current theme if enabled.
Args:
@@ -374,7 +382,9 @@ class SoundThemeManager:
return self._playThemeSound(
soundName,
interrupt=interrupt,
wait=wait
wait=wait,
requireSoundSetting=True,
timeoutSeconds=timeoutSeconds
)
def playFocusModeSound(self):
@@ -389,22 +399,24 @@ class SoundThemeManager:
"""Play sound for button focus (future use)."""
return self.playSound(SOUND_BUTTON)
def playStartSound(self, wait=False):
def playStartSound(self, wait=False, timeoutSeconds=10):
"""Play sound for application startup."""
return self._playThemeSound(
SOUND_START,
interrupt=True,
wait=wait,
requireSoundSetting=True
requireSoundSetting=True,
timeoutSeconds=timeoutSeconds
)
def playStopSound(self, wait=False):
def playStopSound(self, wait=False, timeoutSeconds=10):
"""Play sound for application shutdown."""
return self._playThemeSound(
SOUND_STOP,
interrupt=True,
wait=wait,
requireSoundSetting=True
requireSoundSetting=True,
timeoutSeconds=timeoutSeconds
)
_manager = None
+118 -1
View File
@@ -82,6 +82,108 @@ def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool:
return False
return moduleName.split(".")[-1] == "speechdispatcherfactory"
def _matchesVoiceFamily(candidateFamily: Any, targetFamily: Any) -> bool:
if not candidateFamily or not targetFamily:
return False
for key in (VoiceFamily.NAME, VoiceFamily.LANG, VoiceFamily.DIALECT, VoiceFamily.VARIANT):
if candidateFamily.get(key) != targetFamily.get(key):
return False
return True
def _resolveSpeechDispatcherServerInfo(
moduleName: Optional[str],
speechServerInfo: Optional[Any],
voice: Optional[Any] = None,
fallbackServerInfo: Optional[Any] = None,
) -> Optional[Any]:
if not _isSpeechDispatcherFactory(moduleName):
return speechServerInfo
if speechServerInfo and len(speechServerInfo) >= 2 and speechServerInfo[1] != "default":
return speechServerInfo
if fallbackServerInfo and len(fallbackServerInfo) >= 2 and fallbackServerInfo[1] != "default":
tokens = [
"SPEECH: Resolving Speech Dispatcher server via fallback server info:",
fallbackServerInfo,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return fallbackServerInfo
family = None
if voice:
try:
family = ACSS(voice).get(ACSS.FAMILY)
except Exception:
debug.printException(debug.LEVEL_INFO)
if not family:
try:
family = ACSS(settings.voices.get(settings.DEFAULT_VOICE, {})).get(ACSS.FAMILY)
except Exception:
debug.printException(debug.LEVEL_INFO)
family = None
if not family:
return speechServerInfo
factory = None
try:
factory = importlib.import_module(f"cthulhu.{moduleName}")
except Exception:
try:
factory = importlib.import_module(moduleName)
except Exception:
debug.printException(debug.LEVEL_INFO)
if factory is None:
return speechServerInfo
try:
servers = factory.SpeechServer.getSpeechServers() # type: ignore[attr-defined]
except Exception:
debug.printException(debug.LEVEL_INFO)
return speechServerInfo
for server in servers:
try:
info = server.getInfo()
except Exception:
debug.printException(debug.LEVEL_INFO)
continue
if not info or len(info) < 2 or info[1] == "default":
continue
try:
families = server.getVoiceFamilies() or []
except Exception:
debug.printException(debug.LEVEL_INFO)
continue
for candidate in families:
if not _matchesVoiceFamily(candidate, family):
continue
tokens = [
"SPEECH: Resolved Speech Dispatcher server info",
info,
"for voice family",
family,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return info
tokens = [
"SPEECH: Could not resolve Speech Dispatcher server info for voice family",
family,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return speechServerInfo
def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> SpeechServer:
if not moduleName:
@@ -125,9 +227,24 @@ def _refreshEchoSpeechServer() -> None:
return
try:
resolvedInfo = _resolveSpeechDispatcherServerInfo(
settings.speechServerFactory,
settings.echoSpeechServerInfo,
settings.echoVoice,
settings.speechServerInfo,
)
if resolvedInfo != settings.echoSpeechServerInfo:
tokens = [
"SPEECH: Updating echo speech server info from",
settings.echoSpeechServerInfo,
"to",
resolvedInfo,
]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
settings.echoSpeechServerInfo = resolvedInfo
_echoSpeechserver = _initSpeechServer(
settings.speechServerFactory,
settings.echoSpeechServerInfo
resolvedInfo
)
except Exception:
debug.printException(debug.LEVEL_INFO)
@@ -0,0 +1,58 @@
import importlib
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import messages
notification_script = importlib.import_module("cthulhu.scripts.apps.notification-daemon.script")
class NotificationDaemonTests(unittest.TestCase):
def test_window_created_ignores_empty_labels(self):
testScript = notification_script.Script.__new__(notification_script.Script)
labelEmpty = object()
labelText = object()
eventSource = object()
event = mock.Mock(source=eventSource)
testScript.utilities = mock.Mock()
testScript.utilities.displayedText.side_effect = lambda obj: {
labelEmpty: None,
labelText: "this is a test",
}.get(obj)
testScript.speechGenerator = mock.Mock()
testScript.speechGenerator.voice.return_value = object()
testScript.speakMessage = mock.Mock()
testScript.displayBrailleMessage = mock.Mock()
testScript.notificationPresenter = mock.Mock()
with (
mock.patch.object(
notification_script.AXUtilities,
"find_all_labels",
return_value=[labelEmpty, labelText],
),
mock.patch.object(
notification_script.AXObject,
"get_name",
side_effect=lambda obj: {
labelEmpty: "",
labelText: "",
}.get(obj, ""),
),
):
testScript.onWindowCreated(event)
expectedText = f"{messages.NOTIFICATION} this is a test"
testScript.speechGenerator.voice.assert_called_once_with(obj=eventSource, string=expectedText)
testScript.speakMessage.assert_called_once_with(expectedText, voice=testScript.speechGenerator.voice.return_value)
testScript.displayBrailleMessage.assert_called_once()
testScript.notificationPresenter.save_notification.assert_called_once_with(expectedText)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import settings
from cthulhu import sound_helper
class _FakePlaybin:
def __init__(self):
self.properties = {}
def set_property(self, name, value):
self.properties[name] = value
class SoundHelperBackendTests(unittest.TestCase):
def test_create_file_player_uses_playbin_default_sink_for_auto(self):
fakePlaybin = _FakePlaybin()
with (
mock.patch.object(
sound_helper.Gst.ElementFactory,
"make",
return_value=fakePlaybin,
) as makeElement,
mock.patch.object(
sound_helper.sound_sink,
"create_audio_sink",
) as createAudioSink,
):
player, sinkName, error = sound_helper._create_file_player("worker-file", settings.SOUND_SINK_AUTO)
self.assertIs(player, fakePlaybin)
self.assertEqual(sinkName, "playbin-default")
self.assertIsNone(error)
createAudioSink.assert_not_called()
makeElement.assert_called_once_with("playbin", "worker-file")
self.assertNotIn("audio-sink", fakePlaybin.properties)
def test_create_file_player_sets_explicit_sink_when_requested(self):
fakePlaybin = _FakePlaybin()
fakeSink = object()
with (
mock.patch.object(
sound_helper.Gst.ElementFactory,
"make",
return_value=fakePlaybin,
) as makeElement,
mock.patch.object(
sound_helper.sound_sink,
"create_audio_sink",
return_value=(fakeSink, "pulsesink", None),
) as createAudioSink,
):
player, sinkName, error = sound_helper._create_file_player("worker-file", settings.SOUND_SINK_PULSE)
self.assertIs(player, fakePlaybin)
self.assertEqual(sinkName, "pulsesink")
self.assertIsNone(error)
makeElement.assert_called_once_with("playbin", "worker-file")
createAudioSink.assert_called_once()
self.assertIs(fakePlaybin.properties["audio-sink"], fakeSink)
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
soundGeneratorStub = types.ModuleType("cthulhu.sound_generator")
class _Icon:
def __init__(self, path="", name=""):
self.path = path
self.name = name
def isValid(self):
return True
class _Tone:
def __init__(self, duration=0.1, frequency=440, volume=1.0, wave=0):
self.duration = duration
self.frequency = frequency
self.volume = volume
self.wave = wave
soundGeneratorStub.Icon = _Icon
soundGeneratorStub.Tone = _Tone
sys.modules.setdefault("cthulhu.sound_generator", soundGeneratorStub)
from cthulhu import settings
from cthulhu import sound
from cthulhu import sound_sink
class _FakeProcess:
def poll(self):
return None
class SoundSinkTests(unittest.TestCase):
def test_auto_sink_prefers_autoaudiosink(self):
candidates = sound_sink._get_sink_element_candidates(settings.SOUND_SINK_AUTO)
self.assertGreaterEqual(len(candidates), 1)
self.assertEqual(candidates[0], "autoaudiosink")
class PlayerRecoveryTests(unittest.TestCase):
def test_worker_diagnostic_marks_restart_required(self):
player = sound.Player()
player._handleWorkerDiagnostic("RECOVERY REQUIRED: lost audio sink")
self.assertTrue(player._workerRestartRequired)
self.assertEqual(player._workerRestartReason, "lost audio sink")
def test_ensure_worker_restarts_when_recovery_is_required(self):
player = sound.Player()
player._workerProcess = _FakeProcess()
player._workerSink = settings.SOUND_SINK_AUTO
player._workerRestartRequired = True
player._workerRestartReason = "lost audio sink"
stopReasons = []
startedSinks = []
with (
mock.patch.object(
sound_sink,
"get_configured_sound_sink",
return_value=settings.SOUND_SINK_AUTO,
),
mock.patch.object(
player,
"_stopWorkerLocked",
side_effect=lambda reason: stopReasons.append(reason),
),
mock.patch.object(
player,
"_startWorkerLocked",
side_effect=lambda configuredSink: startedSinks.append(configuredSink) or True,
),
):
self.assertTrue(player._ensureWorkerLocked())
self.assertEqual(stopReasons, ["lost audio sink"])
self.assertEqual(startedSinks, [settings.SOUND_SINK_AUTO])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,85 @@
import re
import sys
import unittest
from pathlib import Path
from unittest import mock
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
soundGeneratorModule = sys.modules.get("cthulhu.sound_generator")
if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "SoundGenerator"):
class _StubSoundGenerator:
pass
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
from cthulhu.scripts.apps.steamwebhelper import script as steam_script
class SteamNotificationRootTests(unittest.TestCase):
def test_prefers_notification_ancestor_over_live_region_descendant(self):
testScript = steam_script.Script.__new__(steam_script.Script)
fragment = object()
notification = object()
def get_attribute(obj, name):
if obj is fragment and name == "container-live":
return "assertive"
return None
def find_ancestor(obj, predicate):
if obj is fragment and predicate(notification):
return notification
return None
with (
mock.patch.object(AXUtilities, "is_notification", side_effect=lambda obj: obj is notification),
mock.patch.object(AXUtilities, "is_alert", return_value=False),
mock.patch.object(AXObject, "get_attribute", side_effect=get_attribute),
mock.patch.object(AXObject, "find_ancestor", side_effect=find_ancestor),
):
result = testScript._findSteamNotificationRoot(fragment)
self.assertIs(result, notification)
class SteamNotificationQueueTests(unittest.TestCase):
def test_merges_non_overlapping_fragments_for_same_notification(self):
testScript = steam_script.Script.__new__(steam_script.Script)
notification = object()
testScript._lastSteamNotification = ("", 0.0)
testScript._steamPendingNotification = None
testScript._steamRelativeTimePattern = re.compile(
r"^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$",
re.IGNORECASE,
)
testScript._resetSteamPendingTimer = mock.Mock()
testScript._presentSteamNotificationTextNow = mock.Mock()
testScript._queueSteamNotification("username", notification)
testScript._queueSteamNotification("Playing: Borderlands 2", notification)
testScript._presentSteamNotificationTextNow.assert_not_called()
self.assertEqual(
testScript._steamPendingNotification["text"],
"username Playing: Game Title",
)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"username Playing: Game Title",
notification,
)
if __name__ == "__main__":
unittest.main()
+112
View File
@@ -0,0 +1,112 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
soundGeneratorModule = sys.modules.get("cthulhu.sound_generator")
if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "SoundGenerator"):
class _StubSoundGenerator:
pass
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
from cthulhu import ax_object
from cthulhu import ax_utilities
from cthulhu.scripts.web import script as web_script
from cthulhu.scripts.web import script_utilities
class WebClickableActivationTests(unittest.TestCase):
def test_return_activates_click_ancestor_on_caret_context(self):
testScript = web_script.Script.__new__(web_script.Script)
caretObject = object()
documentObject = object()
testScript.utilities = mock.Mock()
testScript.utilities.inDocumentContent.return_value = True
testScript.utilities.getCaretContext.return_value = (caretObject, 0)
testScript.utilities.isClickableElement.return_value = False
testScript.inFocusMode = mock.Mock(return_value=False)
testScript.presentMessage = mock.Mock()
testScript._presentDelayedMessage = mock.Mock()
testScript._restoreFocusAfterClick = mock.Mock()
def has_action(obj, action_name):
return obj is caretObject and action_name == "click-ancestor"
with (
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", documentObject),
mock.patch.object(ax_utilities.AXUtilities, "is_entry", return_value=False),
mock.patch.object(ax_utilities.AXUtilities, "is_text", return_value=False),
mock.patch.object(ax_utilities.AXUtilities, "is_password_text", return_value=False),
mock.patch.object(ax_utilities.AXUtilities, "is_combo_box", return_value=False),
mock.patch.object(ax_utilities.AXUtilities, "is_button", return_value=False),
mock.patch.object(ax_utilities.AXUtilities, "is_push_button", return_value=False),
mock.patch.object(ax_utilities.AXUtilities, "is_link", return_value=False),
mock.patch.object(ax_object.AXObject, "has_action", side_effect=has_action),
mock.patch.object(ax_object.AXObject, "do_named_action", return_value=True) as doAction,
mock.patch("cthulhu.ax_event_synthesizer.AXEventSynthesizer.click_object") as clickObject,
):
self.assertTrue(testScript._tryClickableActivation(mock.Mock(event_string="Return")))
doAction.assert_called_once_with(caretObject, "click-ancestor")
clickObject.assert_not_called()
class WebHiddenPopupTests(unittest.TestCase):
def test_hidden_ancestor_marks_descendant_hidden(self):
utilities = script_utilities.Utilities.__new__(script_utilities.Utilities)
child = object()
hiddenParent = object()
utilities.inDocumentContent = mock.Mock(return_value=True)
utilities.objectAttributes = mock.Mock(
side_effect=lambda obj, useCache=False: {
child: {"display": "block"},
hiddenParent: {"display": "none"},
}.get(obj, {})
)
with mock.patch.object(
script_utilities.AXObject,
"get_parent",
side_effect=lambda obj: hiddenParent if obj is child else None,
):
self.assertTrue(utilities.isHidden(child))
def test_focusable_hidden_object_cannot_have_caret_context(self):
utilities = script_utilities.Utilities.__new__(script_utilities.Utilities)
hiddenObject = object()
utilities._canHaveCaretContextDecision = {}
utilities.isZombie = mock.Mock(return_value=False)
utilities.isStaticTextLeaf = mock.Mock(return_value=False)
utilities.isUselessEmptyElement = mock.Mock(return_value=False)
utilities.isOffScreenLabel = mock.Mock(return_value=False)
utilities.isNonNavigablePopup = mock.Mock(return_value=False)
utilities.isUselessImage = mock.Mock(return_value=False)
utilities.isEmptyAnchor = mock.Mock(return_value=False)
utilities.isEmptyToolTip = mock.Mock(return_value=False)
utilities.isParentOfNullChild = mock.Mock(return_value=False)
utilities.isPseudoElement = mock.Mock(return_value=False)
utilities.isFakePlaceholderForEntry = mock.Mock(return_value=False)
utilities.isNonInteractiveDescendantOfControl = mock.Mock(return_value=False)
utilities.isHidden = mock.Mock(return_value=True)
utilities.hasNoSize = mock.Mock(return_value=False)
with (
mock.patch.object(script_utilities.AXObject, "is_dead", return_value=False),
mock.patch.object(script_utilities.AXUtilities, "is_focusable", return_value=True),
):
self.assertFalse(utilities._canHaveCaretContext(hiddenObject))
if __name__ == "__main__":
unittest.main()