7 Commits
38 changed files with 2923 additions and 245 deletions
+15
View File
@@ -14,6 +14,16 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s
- `meson compile -C _build` - `meson compile -C _build`
- `meson install -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 ## Coding guidelines
- **When modifying existing code:** follow the surrounding codes conventions. - **When modifying existing code:** follow the surrounding codes conventions.
- **When writing new code from scratch:** prefer - **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) ## 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 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. - 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.
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=Cthulhu Screen Reader
Exec=cthulhu
NoDisplay=true
# Desktop-neutral autostart - no GNOME-specific conditions
# Users can enable/disable via their desktop environment's accessibility settings
# or by adding/removing this file from ~/.config/autostart/
X-GNOME-AutoRestart=true
Categories=Accessibility;
Keywords=screen;reader;accessibility;speech;braille;
+7 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu pkgname=cthulhu
pkgver=2026.03.02 pkgver=2026.02.22
pkgrel=1 pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
@@ -84,7 +84,7 @@ makedepends=(
) )
install=cthulhu.install install=cthulhu.install
source=( source=(
"git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}" "git+https://git.stormux.org/storm/cthulhu.git"
"cthulhu.install" "cthulhu.install"
) )
b2sums=( b2sums=(
@@ -92,6 +92,11 @@ b2sums=(
'SKIP' 'SKIP'
) )
pkgver() {
cd cthulhu
grep "^version = " src/cthulhu/cthulhuVersion.py | sed 's/version = "\(.*\)"/\1/'
}
build() { build() {
cd cthulhu cd cthulhu
arch-meson _build arch-meson _build
+1 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu', project('cthulhu',
version: '2026.03.02-master', version: '2026.02.22-testing',
meson_version: '>= 1.0.0', meson_version: '>= 1.0.0',
) )
+36 -1
View File
@@ -204,11 +204,22 @@ class AXHypertext:
@staticmethod @staticmethod
def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Optional[Atspi.Accessible]: 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): if child := AXHypertext.get_child_at_offset(obj, offset):
offset_in_parent = AXHypertext.get_character_offset_in_parent(child)
if offset_in_parent == offset:
return child 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): if child_before := AXHypertext.get_child_at_offset(obj, offset - 1):
offset_in_parent = AXHypertext.get_character_offset_in_parent(child_before) offset_in_parent = AXHypertext.get_character_offset_in_parent(child_before)
if offset_in_parent == offset: if offset_in_parent == offset:
@@ -225,6 +236,30 @@ class AXHypertext:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return child_after 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 return None
@staticmethod @staticmethod
+50 -5
View File
@@ -571,7 +571,7 @@
<property name="use_underline">True</property> <property name="use_underline">True</property>
<property name="mnemonic_widget">timeFormatCombo</property> <property name="mnemonic_widget">timeFormatCombo</property>
<accessibility> <accessibility>
<relation type="label-for" target="availableProfilesComboBox1"/> <relation type="label-for" target="timeFormatCombo"/>
</accessibility> </accessibility>
</object> </object>
<packing> <packing>
@@ -588,7 +588,7 @@
<property name="use_underline">True</property> <property name="use_underline">True</property>
<property name="mnemonic_widget">dateFormatCombo</property> <property name="mnemonic_widget">dateFormatCombo</property>
<accessibility> <accessibility>
<relation type="label-for" target="availableProfilesComboBox2"/> <relation type="label-for" target="dateFormatCombo"/>
</accessibility> </accessibility>
</object> </object>
<packing> <packing>
@@ -1053,6 +1053,51 @@
<property name="can_focus">False</property> <property name="can_focus">False</property>
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="spacing">6</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> <child>
<object class="GtkBox" id="soundThemeHBox"> <object class="GtkBox" id="soundThemeHBox">
<property name="visible">True</property> <property name="visible">True</property>
@@ -1095,7 +1140,7 @@
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
<property name="fill">True</property> <property name="fill">True</property>
<property name="position">0</property> <property name="position">1</property>
</packing> </packing>
</child> </child>
<child> <child>
@@ -1140,7 +1185,7 @@
<packing> <packing>
<property name="expand">False</property> <property name="expand">False</property>
<property name="fill">True</property> <property name="fill">True</property>
<property name="position">1</property> <property name="position">2</property>
</packing> </packing>
</child> </child>
</object> </object>
@@ -1151,7 +1196,7 @@
<object class="GtkLabel" id="soundThemeTitleLabel"> <object class="GtkLabel" id="soundThemeTitleLabel">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can_focus">False</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> <attributes>
<attribute name="weight" value="bold"/> <attribute name="weight" value="bold"/>
</attributes> </attributes>
+10 -2
View File
@@ -769,7 +769,7 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) ->
cthulhu_state.activeScript.presentationInterrupt() cthulhu_state.activeScript.presentationInterrupt()
cthulhuApp.getSignalManager().emitSignal('stop-application-completed') 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) cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
# Deactivate the event manager first so that it clears its queue and will not # 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) debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initialized.", True)
script = cthulhu_state.activeScript 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') cthulhuApp.getSignalManager().emitSignal('start-application-completed')
if script: if script:
window = script.utilities.activeWindow() window = script.utilities.activeWindow()
@@ -944,6 +951,7 @@ class Cthulhu(GObject.Object):
self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate
self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate
self.scriptManager: ScriptManager = script_manager.ScriptManager(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.logger: logger.Logger = logger.Logger() # Directly instantiate
self.signalManager: SignalManager = signal_manager.SignalManager(self) self.signalManager: SignalManager = signal_manager.SignalManager(self)
self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self) self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
+2 -2
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader. # Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu # Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.03.02" version = "2026.02.22"
codeName = "master" codeName = "testing"
+261 -5
View File
@@ -60,6 +60,7 @@ from . import cthulhu_gui_profile
from . import cthulhu_state from . import cthulhu_state
from . import settings from . import settings
from . import settings_manager from . import settings_manager
from . import sound_sink
from . import input_event from . import input_event
from . import input_event_manager from . import input_event_manager
from . import keybindings from . import keybindings
@@ -71,6 +72,7 @@ from . import text_attribute_names
from . import sound_theme_manager from . import sound_theme_manager
from . import script_manager from . import script_manager
from .ax_object import AXObject from .ax_object import AXObject
from .ax_utilities import AXUtilities
_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager _settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager
@@ -194,6 +196,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.savedPitch = None self.savedPitch = None
self.savedRate = None self.savedRate = None
self.soundThemeCombo = None self.soundThemeCombo = None
self.soundSinkCombo = None
self.roleSoundPresentationCombo = None self.roleSoundPresentationCombo = None
self._isInitialSetup = False self._isInitialSetup = False
self._updatingSpeechFamilies = False self._updatingSpeechFamilies = False
@@ -202,6 +205,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.profilesCombo = None self.profilesCombo = None
self.profilesComboModel = None self.profilesComboModel = None
self.startingProfileCombo = None self.startingProfileCombo = None
self._initialFocusSyncAttempts = 0
self._capturedKey = [] self._capturedKey = []
self.script = None self.script = None
@@ -1506,6 +1510,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# #
if not serverInfo: if not serverInfo:
serverInfo = speech.getInfo() 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 valueSet = False
i = 0 i = 0
@@ -1720,6 +1734,118 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
} }
self.echoVoice['established'] = True 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): def _populateEchoSpeechFamilies(self, families):
"""Populate the echo family combobox from the provided families list.""" """Populate the echo family combobox from the provided families list."""
@@ -1873,6 +1999,23 @@ print(json.dumps(result))
self._setupEchoSpeechFamilies() self._setupEchoSpeechFamilies()
return 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 valueSet = False
for i, server in enumerate(self.echoSpeechServersChoices): for i, server in enumerate(self.echoSpeechServersChoices):
info = server.getInfo() info = server.getInfo()
@@ -2961,15 +3104,37 @@ print(json.dumps(result))
self.ocrCopyToClipboardCheckButton.set_active(copyToClipboard) self.ocrCopyToClipboardCheckButton.set_active(copyToClipboard)
def _initSoundThemeState(self): def _initSoundThemeState(self):
"""Initialize Sound Theme widgets with current settings.""" """Initialize sound widgets with current settings."""
prefs = self.prefsDict prefs = self.prefsDict
# Get widget references # Get widget references
self.soundSinkCombo = self.get_widget("soundSinkCombo")
self.soundThemeCombo = self.get_widget("soundThemeCombo") self.soundThemeCombo = self.get_widget("soundThemeCombo")
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo") self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
self.soundSinkCombo.set_can_focus(False)
self.soundThemeCombo.set_can_focus(False) self.soundThemeCombo.set_can_focus(False)
self.roleSoundPresentationCombo.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 # Populate sound theme combo box
themeManager = sound_theme_manager.getManager() themeManager = sound_theme_manager.getManager()
availableThemes = themeManager.getAvailableThemes() availableThemes = themeManager.getAvailableThemes()
@@ -3018,6 +3183,14 @@ print(json.dumps(result))
if activeText: if activeText:
self.prefsDict["soundTheme"] = 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): def roleSoundPresentationComboChanged(self, widget):
"""Signal handler for the role sound presentation combo box.""" """Signal handler for the role sound presentation combo box."""
activeIndex = widget.get_active() activeIndex = widget.get_active()
@@ -3148,6 +3321,68 @@ print(json.dumps(result))
cthulhuSetupWindow.set_title(title) cthulhuSetupWindow.set_title(title)
cthulhuSetupWindow.show() 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): def _initComboBox(self, combobox):
"""Initialize the given combo box to take a list of int/str pairs. """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: if not self._isInitialSetup:
self.restoreSettings() 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() enable = self.get_widget("speechSupportCheckButton").get_active()
self.prefsDict["enableSpeech"] = enable self.prefsDict["enableSpeech"] = enable
@@ -4648,9 +4888,24 @@ print(json.dumps(result))
self.prefsDict["speechServerFactory"] = \ self.prefsDict["speechServerFactory"] = \
self.speechSystemsChoice.__name__ self.speechSystemsChoice.__name__
if self.speechServersChoice: speechServerChoice = self._getSpeechServerChoiceForSave()
if speechServerChoice:
self.prefsDict["speechServerInfo"] = \ 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: if self.defaultVoice is not None:
self.prefsDict["voices"] = { self.prefsDict["voices"] = {
@@ -4684,8 +4939,9 @@ print(json.dumps(result))
self.echoVoice['established'] = True self.echoVoice['established'] = True
self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice) self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice)
if self.echoSpeechServersChoice: echoSpeechServerChoice = self._getEchoSpeechServerChoiceForSave()
self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo() if echoSpeechServerChoice:
self.prefsDict["echoSpeechServerInfo"] = echoSpeechServerChoice.getInfo()
else: else:
self.prefsDict["echoSpeechServerInfo"] = None self.prefsDict["echoSpeechServerInfo"] = None
+5
View File
@@ -47,6 +47,11 @@ __license__ = "LGPL"
# #
locusOfFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible] 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. # The currently active window.
# #
activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible] activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
+140
View File
@@ -70,7 +70,9 @@ class EventManager:
self._dequeueCount: int = 0 self._dequeueCount: int = 0
self._cmdlineCache: Dict[int, str] = {} self._cmdlineCache: Dict[int, str] = {}
self._eventQueue: queue.Queue[Any] = queue.Queue(0) self._eventQueue: queue.Queue[Any] = queue.Queue(0)
self._prioritizedEvent: Optional[Atspi.Event] = None
self._gidleId: int = 0 self._gidleId: int = 0
self._prioritizedIdleId: int = 0
self._gidleLock: threading.Lock = threading.Lock() self._gidleLock: threading.Lock = threading.Lock()
self._gilSleepTime: float = 0.00001 self._gilSleepTime: float = 0.00001
self._synchronousToolkits: List[str] = ['VCL'] self._synchronousToolkits: List[str] = ['VCL']
@@ -284,6 +286,12 @@ class EventManager:
if self._isDuplicateEvent(event): if self._isDuplicateEvent(event):
return _ignore_with_reason("duplicate", "duplicate 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. # Thunderbird spams us with these when a message list thread is expanded or collapsed.
if event.type.endswith('system') \ if event.type.endswith('system') \
and AXObject.get_name(app).lower().startswith('thunderbird'): and AXObject.get_name(app).lower().startswith('thunderbird'):
@@ -596,6 +604,11 @@ class EventManager:
self._enqueueCount -= 1 self._enqueueCount -= 1
return return
if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e):
if debug.debugEventQueue:
self._enqueueCount -= 1
return
self._queuePrintln(e) self._queuePrintln(e)
if self._inFlood() and self._prioritizeDuringFlood(e): if self._inFlood() and self._prioritizeDuringFlood(e):
@@ -628,6 +641,73 @@ class EventManager:
if debug.debugEventQueue: if debug.debugEventQueue:
self._enqueueCount -= 1 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: def _isNoFocus(self) -> bool:
if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript: if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript:
return False return False
@@ -645,6 +725,60 @@ class EventManager:
defaultScript.idleMessage() defaultScript.idleMessage()
return False 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: def _dequeue(self) -> bool:
"""Handles all events destined for scripts. Called by the GTK """Handles all events destined for scripts. Called by the GTK
idle thread.""" idle thread."""
@@ -656,8 +790,14 @@ class EventManager:
debug.printMessage(debug.LEVEL_ALL, msg, True) debug.printMessage(debug.LEVEL_ALL, msg, True)
self._dequeueCount += 1 self._dequeueCount += 1
try:
fromPriority = False
self._gidleLock.acquire()
try: try:
event = self._eventQueue.get_nowait() event = self._eventQueue.get_nowait()
finally:
self._gidleLock.release()
self._queuePrintln(event, isEnqueue=False) self._queuePrintln(event, isEnqueue=False)
inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent) inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
if isinstance(event, inputEvents): if isinstance(event, inputEvents):
+31 -3
View File
@@ -116,6 +116,13 @@ class FocusManager:
_log_tokens(["Focused object in", self._window, "is", result]) _log_tokens(["Focused object in", self._window, "is", result])
return 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: def focus_and_window_are_unknown(self) -> bool:
"""Returns True if we have no knowledge about what is focused.""" """Returns True if we have no knowledge about what is focused."""
@@ -350,18 +357,39 @@ class FocusManager:
self._window = frame self._window = frame
cthulhu_state.activeWindow = frame cthulhu_state.activeWindow = frame
contextObject = self._focus
if set_window_as_focus: if set_window_as_focus:
self.set_locus_of_focus(None, self._window, notify_script) self.set_locus_of_focus(None, self._window, notify_script)
elif not (self.focus_is_active_window() or self.focus_is_in_active_window()): 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) _log_tokens(["Focus", self._focus, "is not in", self._window], stack=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 # 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 # from the focused object. https://bugreports.qt.io/browse/QTBUG-130116
if not AXObject.has_broken_ancestry(self._focus): elif not AXObject.has_broken_ancestry(self._focus):
self.set_locus_of_focus(None, self._window, notify_script=True) self.set_locus_of_focus(None, self._window, notify_script=True)
contextObject = self._window
app = _get_ax_utilities().get_application(self._focus) if contextObject is None:
self.app.scriptManager.activate_script_for_context(app, self._focus, "focus: active-window") 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 @dbus_service.command
def toggle_presentation_mode( 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. # audio files that Cthulhu plays for various events.
SOUND_THEME = _("Sound _theme:") 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 # Translators: This is the title of a frame in the preferences dialog
# containing sound theme options. # containing sound options.
SOUND_THEME_TITLE = _("Sound Theme") SOUND_THEME_TITLE = _("Sound")
# Translators: This refers to the amount of information Cthulhu provides about a # Translators: This refers to the amount of information Cthulhu provides about a
# particular object that receives focus. # particular object that receives focus.
+3 -1
View File
@@ -870,6 +870,8 @@ class KeyboardEvent(InputEvent):
return True, 'Cthulhu modifier' return True, 'Cthulhu modifier'
if not self._handler: if not self._handler:
if scriptConsumes:
return True, 'Script consumed without handler'
return False, 'No handler' return False, 'No handler'
return scriptConsumes, 'Script indication' return scriptConsumes, 'Script indication'
@@ -1044,7 +1046,7 @@ class KeyboardEvent(InputEvent):
return False, 'Should not consume' return False, 'Should not consume'
if not (self._consumer or self._handler): if not (self._consumer or self._handler):
return False, 'No consumer or handler' return True, 'Consumed during shouldConsume'
if self._consumer or self._handler.function: if self._consumer or self._handler.function:
GLib.timeout_add(1, self._consume) GLib.timeout_add(1, self._consume)
+41 -6
View File
@@ -277,6 +277,23 @@ class InputEventManager:
self._last_input_event = event self._last_input_event = event
self._last_non_modifier_key_event = None 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-arguments
# pylint: disable=too-many-positional-arguments # pylint: disable=too-many-positional-arguments
def process_keyboard_event( def process_keyboard_event(
@@ -306,6 +323,10 @@ class InputEventManager:
return False return False
manager = focus_manager.get_manager() 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: if pressed:
window = manager.get_active_window() window = manager.get_active_window()
if not AXUtilities.can_be_active_window(window, clear_cache=True): 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) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
manager.set_active_window(window) manager.set_active_window(window)
else: else:
# One example: Brave's popup menus live in frames which lack the active state. focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus())
tokens = ["WARNING:", window, "cannot be active window. No alternative found."] 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) debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
window = None
manager.set_active_window(None, notify_script=True)
event.set_window(window) 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()) event.set_script(script_manager.get_manager().get_active_script())
elif self.last_event_was_keyboard(): elif self.last_event_was_keyboard():
assert isinstance(self._last_input_event, input_event.KeyboardEvent) assert isinstance(self._last_input_event, input_event.KeyboardEvent)
@@ -331,7 +366,7 @@ class InputEventManager:
event.set_script(self._last_input_event.get_script()) event.set_script(self._last_input_event.get_script())
else: else:
event.set_window(manager.get_active_window()) 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.set_script(script_manager.get_manager().get_active_script())
event._finalize_initialization() event._finalize_initialization()
+2
View File
@@ -86,6 +86,8 @@ cthulhu_python_sources = files([
'signal_manager.py', 'signal_manager.py',
'sleep_mode_manager.py', 'sleep_mode_manager.py',
'sound.py', 'sound.py',
'sound_helper.py',
'sound_sink.py',
'sound_generator.py', 'sound_generator.py',
'sound_theme_manager.py', 'sound_theme_manager.py',
'speech_and_verbosity_manager.py', 'speech_and_verbosity_manager.py',
+8 -3
View File
@@ -41,6 +41,7 @@ else:
_gstreamerAvailable, args = Gst.init_check(None) _gstreamerAvailable, args = Gst.init_check(None)
from . import debug from . import debug
from . import sound_sink
class PiperAudioPlayer: class PiperAudioPlayer:
@@ -84,6 +85,7 @@ class PiperAudioPlayer:
try: try:
self._pipeline = Gst.Pipeline.new("piper-audio") self._pipeline = Gst.Pipeline.new("piper-audio")
configuredSink = sound_sink.get_configured_sound_sink()
self._appsrc = Gst.ElementFactory.make("appsrc", "source") self._appsrc = Gst.ElementFactory.make("appsrc", "source")
if self._appsrc is None: if self._appsrc is None:
@@ -110,9 +112,9 @@ class PiperAudioPlayer:
debug.printMessage(debug.LEVEL_WARNING, msg, True) debug.printMessage(debug.LEVEL_WARNING, msg, True)
return False return False
sink = Gst.ElementFactory.make("autoaudiosink", "sink") sink, sinkName, sinkError = sound_sink.create_audio_sink("sink", configuredSink)
if sink is None: 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) debug.printMessage(debug.LEVEL_WARNING, msg, True)
return False return False
@@ -141,7 +143,10 @@ class PiperAudioPlayer:
bus.connect("message", self._onMessage) bus.connect("message", self._onMessage)
self._initialized = True 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) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True return True
+1 -1
View File
@@ -473,5 +473,5 @@ def get_manager() -> Optional[ScriptManager]:
if _manager is None: if _manager is None:
from . import cthulhu from . import cthulhu
if cthulhu.cthulhuApp: if cthulhu.cthulhuApp:
_manager = ScriptManager(cthulhu.cthulhuApp) _manager = cthulhu.cthulhuApp.scriptManager
return _manager return _manager
@@ -34,6 +34,7 @@ __license__ = "LGPL"
import cthulhu.messages as messages import cthulhu.messages as messages
import cthulhu.scripts.default as default import cthulhu.scripts.default as default
import cthulhu.settings as settings import cthulhu.settings as settings
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities import AXUtilities
@@ -49,8 +50,15 @@ class Script(default.Script):
"""Callback for window:create accessibility events.""" """Callback for window:create accessibility events."""
allLabels = AXUtilities.find_all_labels(event.source) allLabels = AXUtilities.find_all_labels(event.source)
texts = [self.utilities.displayedText(acc) for acc in allLabels] texts = []
text = f"{messages.NOTIFICATION} {' '.join(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) voice = self.speechGenerator.voice(obj=event.source, string=text)
self.speakMessage(text, voice=voice) self.speakMessage(text, voice=voice)
@@ -1,6 +1,7 @@
steamwebhelper_python_sources = files([ steamwebhelper_python_sources = files([
'__init__.py', '__init__.py',
'script.py', 'script.py',
'script_utilities.py',
]) ])
python3.install_sources( python3.install_sources(
@@ -42,6 +42,7 @@ from cthulhu.ax_utilities import AXUtilities
from cthulhu.ax_utilities_relation import AXUtilitiesRelation from cthulhu.ax_utilities_relation import AXUtilitiesRelation
from cthulhu.ax_utilities_role import AXUtilitiesRole from cthulhu.ax_utilities_role import AXUtilitiesRole
from cthulhu.scripts.toolkits import Chromium from cthulhu.scripts.toolkits import Chromium
from .script_utilities import Utilities
settingsManager = settings_manager.getManager() settingsManager = settings_manager.getManager()
@@ -88,6 +89,13 @@ class Script(Chromium.Script):
re.IGNORECASE re.IGNORECASE
) )
def shouldConsumeKeyboardEvent(self, keyboardEvent, handler) -> bool:
consumes = super().shouldConsumeKeyboardEvent(keyboardEvent, handler)
if consumes:
return True
return self._trySteamButtonActivation(keyboardEvent)
def onShowingChanged(self, event): def onShowingChanged(self, event):
"""Callback for object:state-changed:showing accessibility events.""" """Callback for object:state-changed:showing accessibility events."""
@@ -99,6 +107,9 @@ class Script(Chromium.Script):
# Fall through to Chromium/web handling # Fall through to Chromium/web handling
super().onShowingChanged(event) super().onShowingChanged(event)
def getUtilities(self):
return Utilities(self)
def onChildrenAdded(self, event): def onChildrenAdded(self, event):
"""Callback for object:children-changed:add accessibility events.""" """Callback for object:children-changed:add accessibility events."""
@@ -154,6 +165,40 @@ class Script(Chromium.Script):
self._logSteamNavigationEvent("active-descendant-changed", event) self._logSteamNavigationEvent("active-descendant-changed", event)
return super().onActiveDescendantChanged(event) return super().onActiveDescendantChanged(event)
def _trySteamButtonActivation(self, keyboardEvent) -> bool:
if keyboardEvent.event_string not in ["Return", "KP_Enter"]:
return False
if not keyboardEvent.is_pressed_key():
return False
if getattr(keyboardEvent, "modifiers", 0):
return False
obj = self._getClickableActivationTarget()
if not obj or not self.utilities.inDocumentContent(obj):
return False
if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)):
return False
if not any(
AXObject.has_action(obj, actionName)
for actionName in ["press", "click", "click-ancestor", "activate", "open", "jump"]
):
return False
if self._performClickableAction(obj):
return True
from cthulhu import ax_event_synthesizer
result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
if result:
self._restoreFocusAfterClick(obj)
return True
return False
def _isSteamNotification(self, obj): def _isSteamNotification(self, obj):
"""Detect if object is a Steam notification. """Detect if object is a Steam notification.
@@ -203,19 +248,24 @@ class Script(Chromium.Script):
if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj): if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
return 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') liveAttr = AXObject.get_attribute(obj, 'live')
containerLive = AXObject.get_attribute(obj, 'container-live') containerLive = AXObject.get_attribute(obj, 'container-live')
if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']: if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']:
return obj return obj
def isNotificationCandidate(candidate): def isLiveRegionCandidate(candidate):
if AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate):
return True
candidateLive = AXObject.get_attribute(candidate, 'live') candidateLive = AXObject.get_attribute(candidate, 'live')
candidateContainerLive = AXObject.get_attribute(candidate, 'container-live') candidateContainerLive = AXObject.get_attribute(candidate, 'container-live')
return candidateLive in ['assertive', 'polite'] or candidateContainerLive in ['assertive', 'polite'] 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): def _presentSteamNotification(self, obj):
"""Speak and save the notification. """Speak and save the notification.
@@ -248,12 +298,15 @@ class Script(Chromium.Script):
return f"string('{text}')" return f"string('{text}')"
if anyData is None: if anyData is None:
return "None" return "None"
if isinstance(anyData, (bool, int, float)):
return repr(anyData)
return self._describeSteamObject(anyData) return self._describeSteamObject(anyData)
def _describeSteamObject(self, obj): def _describeSteamObject(self, obj):
if obj is None: if obj is None:
return "None" return "None"
try:
name = AXObject.get_name(obj) or "" name = AXObject.get_name(obj) or ""
description = AXObject.get_description(obj) or "" description = AXObject.get_description(obj) or ""
roleName = AXObject.get_role_name(obj) or "" roleName = AXObject.get_role_name(obj) or ""
@@ -271,6 +324,11 @@ class Script(Chromium.Script):
f"text='{text}' " f"text='{text}' "
f"path={path}" f"path={path}"
) )
except Exception as error:
return (
f"uninspectable(type={type(obj).__name__}, "
f"value={obj!r}, error={error})"
)
def _presentSteamLiveRegionText(self, event): def _presentSteamLiveRegionText(self, event):
if not isinstance(event.any_data, str): if not isinstance(event.any_data, str):
@@ -363,6 +421,27 @@ class Script(Chromium.Script):
return f"{baseText} {timestampText}" return f"{baseText} {timestampText}"
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): def _steamTextContains(self, text, other):
textNorm = self._normalizeSteamNotificationText(text).lower() textNorm = self._normalizeSteamNotificationText(text).lower()
otherNorm = self._normalizeSteamNotificationText(other).lower() otherNorm = self._normalizeSteamNotificationText(other).lower()
@@ -376,6 +455,7 @@ class Script(Chromium.Script):
text = self._normalizeSteamNotificationText(text) text = self._normalizeSteamNotificationText(text)
if not text: if not text:
return return
sourceKey = self._getSteamNotificationIdentity(obj)
pending = self._steamPendingNotification pending = self._steamPendingNotification
if self._isSteamRelativeTimestamp(text): if self._isSteamRelativeTimestamp(text):
@@ -386,6 +466,7 @@ class Script(Chromium.Script):
pending["text"] = self._appendSteamTimestamp(pending["text"], text) pending["text"] = self._appendSteamTimestamp(pending["text"], text)
if obj: if obj:
pending["obj"] = obj pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer() self._resetSteamPendingTimer()
return return
@@ -393,7 +474,8 @@ class Script(Chromium.Script):
"text": text, "text": text,
"obj": obj, "obj": obj,
"timerId": None, "timerId": None,
"timestampOnly": True "timestampOnly": True,
"sourceKey": sourceKey
} }
self._resetSteamPendingTimer() self._resetSteamPendingTimer()
return return
@@ -405,24 +487,39 @@ class Script(Chromium.Script):
pending["timestampOnly"] = False pending["timestampOnly"] = False
if obj: if obj:
pending["obj"] = obj pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer() self._resetSteamPendingTimer()
return return
if text == pendingText: if text == pendingText:
if obj: if obj:
pending["obj"] = obj pending["obj"] = obj
pending["sourceKey"] = sourceKey
return return
if self._steamTextContains(text, pendingText): if self._steamTextContains(text, pendingText):
pending["text"] = text pending["text"] = text
if obj: if obj:
pending["obj"] = obj pending["obj"] = obj
pending["sourceKey"] = sourceKey
self._resetSteamPendingTimer() self._resetSteamPendingTimer()
return return
if self._steamTextContains(pendingText, text): if self._steamTextContains(pendingText, text):
if obj: if obj:
pending["obj"] = 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 return
self._flushSteamPendingNotification(fromTimer=False) self._flushSteamPendingNotification(fromTimer=False)
@@ -431,7 +528,8 @@ class Script(Chromium.Script):
"text": text, "text": text,
"obj": obj, "obj": obj,
"timerId": None, "timerId": None,
"timestampOnly": False "timestampOnly": False,
"sourceKey": sourceKey
} }
self._resetSteamPendingTimer() self._resetSteamPendingTimer()
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 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.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Cthulhu project: https://git.stormux.org/storm/cthulhu
"""Steam-specific utility helpers."""
from __future__ import annotations
from typing import Optional
from cthulhu import debug
from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText
from cthulhu.ax_utilities import AXUtilities
from cthulhu.scripts.toolkits.Chromium.script_utilities import Utilities as ChromiumUtilities
class Utilities(ChromiumUtilities):
def __init__(self, script) -> None:
super().__init__(script)
self._steamInferredButtonLabels: dict[int, str] = {}
def clearCachedObjects(self) -> None:
super().clearCachedObjects()
self._steamInferredButtonLabels = {}
def displayedLabel(self, obj):
label = super().displayedLabel(obj)
if label or not self._shouldInferSteamButtonLabel(obj):
return label
inferredLabel = self._getSteamInferredButtonLabel(obj)
if inferredLabel:
self._displayedLabelText[hash(obj)] = inferredLabel
return inferredLabel
def displayedText(self, obj):
text = super().displayedText(obj)
if text or not self._shouldInferSteamButtonLabel(obj):
return text
inferredLabel = self._getSteamInferredButtonLabel(obj)
if inferredLabel:
cache = self._script.generatorCache.setdefault(self.DISPLAYED_TEXT, {})
cache[obj] = inferredLabel
return inferredLabel
def _shouldInferSteamButtonLabel(self, obj) -> bool:
if not (obj and self.inDocumentContent(obj)):
return False
if AXObject.get_name(obj):
return False
if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)):
return False
className = AXObject.get_attribute(obj, "class") or ""
return "FriendsListTab" in className or "AddFriendButton" in className
def _getSteamInferredButtonLabel(self, obj) -> str:
cached = self._steamInferredButtonLabels.get(hash(obj))
if cached is not None:
return cached
className = AXObject.get_attribute(obj, "class") or ""
inferredLabel = self._getSteamButtonLabelFromClass(className)
if not inferredLabel:
inferredLabel = self._getSteamNearbyButtonLabel(obj)
inferredLabel = inferredLabel or ""
self._steamInferredButtonLabels[hash(obj)] = inferredLabel
if inferredLabel:
tokens = ["STEAM: Inferred label for", obj, ":", inferredLabel]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return inferredLabel
@staticmethod
def _getSteamButtonLabelFromClass(className: str) -> str:
if "AddFriendButton" in className:
return "Add Friend"
return ""
def _getSteamNearbyButtonLabel(self, obj) -> str:
parent = AXObject.get_parent(obj)
if parent is None:
return ""
siblingLabel = self._getSteamLabelFromChildren(parent, ignore=obj)
if siblingLabel:
return siblingLabel
parentLabel = self._getSteamReadableText(parent)
if self._isUsefulSteamLabel(parentLabel):
return parentLabel
grandParent = AXObject.get_parent(parent)
if grandParent is None:
return ""
return self._getSteamLabelFromChildren(grandParent, ignore=parent)
def _getSteamLabelFromChildren(self, obj, ignore=None) -> str:
for child in AXObject.iter_children(obj):
if child == ignore:
continue
if AXUtilities.is_button(child) or AXUtilities.is_push_button(child):
continue
label = self._getSteamReadableText(child)
if self._isUsefulSteamLabel(label):
return label
return ""
def _getSteamReadableText(self, obj) -> str:
if obj is None:
return ""
name = self._normalizeSteamLabelText(AXObject.get_name(obj) or "")
if self._isUsefulSteamLabel(name):
return name
if not AXObject.supports_text(obj):
return ""
text = AXText.get_all_text(obj) or ""
text = text.replace(self.EMBEDDED_OBJECT_CHARACTER, " ")
text = self._normalizeSteamLabelText(text)
if self._isUsefulSteamLabel(text):
return text
return ""
@staticmethod
def _normalizeSteamLabelText(text: str) -> str:
return " ".join(text.split())
@staticmethod
def _isUsefulSteamLabel(text: Optional[str]) -> bool:
if not text:
return False
return not text.isdigit() and text.casefold() != "unlabeled image"
+8
View File
@@ -1925,6 +1925,14 @@ class Script(script.Script):
self.pointOfReference = {} self.pointOfReference = {}
cthulhu.setActiveWindow(window) 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): if self.utilities.isKeyGrabEvent(event):
msg = "DEFAULT: Ignoring event. Likely from key grab." msg = "DEFAULT: Ignoring event. Likely from key grab."
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
+41 -10
View File
@@ -1450,10 +1450,36 @@ class Script(default.Script):
return None 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): def _tryClickableActivation(self, keyboardEvent):
"""Try to activate clickable element - returns True if we should consume the event.""" """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): if not obj or not self.utilities.inDocumentContent(obj):
return False return False
@@ -1474,28 +1500,33 @@ class Script(default.Script):
# First try the standard clickable detection # First try the standard clickable detection
if self.utilities.isClickableElement(obj): if self.utilities.isClickableElement(obj):
from cthulhu import ax_event_synthesizer
# Give immediate feedback that activation is starting
self.presentMessage("Activating...") 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) result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
if result: if result:
# Schedule success message after a brief delay
self._presentDelayedMessage("Element activated", 50) self._presentDelayedMessage("Element activated", 50)
# Try to restore focus to the clicked element after a brief delay
self._restoreFocusAfterClick(original_focus) self._restoreFocusAfterClick(original_focus)
return True return True
# If that didn't work, try a more permissive approach for any element with click action # If that didn't work, try a more permissive approach for any element with click action
from cthulhu import ax_object from cthulhu import ax_object
if ax_object.AXObject.has_action(obj, "click"): if ax_object.AXObject.has_action(obj, "click") \
from cthulhu import ax_event_synthesizer or ax_object.AXObject.has_action(obj, "click-ancestor"):
# Give immediate feedback that activation is starting
self.presentMessage("Activating...") 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) result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
if result: if result:
# Schedule success message after a brief delay
self._presentDelayedMessage("Element activated", 50) self._presentDelayedMessage("Element activated", 50)
# Try to restore focus to the clicked element after a brief delay
self._restoreFocusAfterClick(original_focus) self._restoreFocusAfterClick(original_focus)
return True return True
+32 -4
View File
@@ -618,8 +618,24 @@ class Utilities(script_utilities.Utilities):
return self.queryNonEmptyText(obj, False) is None return self.queryNonEmptyText(obj, False) is None
def isHidden(self, obj): def isHidden(self, obj):
attrs = self.objectAttributes(obj, False) hiddenValues = {"true", "1", "yes"}
return attrs.get('hidden', False) 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): def _isOrIsIn(self, child, parent):
if not (child and parent): if not (child and parent):
@@ -4772,10 +4788,20 @@ class Utilities(script_utilities.Utilities):
startTime = time.time() startTime = time.time()
rv = None rv = None
if AXUtilities.is_focusable(obj): if AXUtilities.is_focusable(obj):
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] tokens = ["WEB: Focusable object can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = True rv = True
elif AXUtilities.is_editable(obj): elif AXUtilities.is_editable(obj):
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] tokens = ["WEB: Editable object can have caret context", obj]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
rv = True rv = True
@@ -5067,7 +5093,9 @@ class Utilities(script_utilities.Utilities):
obj, offset = None, -1 obj, offset = None, -1
notify = True notify = True
lastWasUp = input_event_manager.get_manager().last_event_was_up() manager = input_event_manager.get_manager()
lastWasUp = manager.last_event_was_up()
lastWasDown = manager.last_event_was_down()
childCount = AXObject.get_child_count(event.source) childCount = AXObject.get_child_count(event.source)
if lastWasUp: if lastWasUp:
if event.detail1 >= childCount: if event.detail1 >= childCount:
@@ -5086,7 +5114,7 @@ class Utilities(script_utilities.Utilities):
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
obj, offset = self.previousContext(prevObj, -1) obj, offset = self.previousContext(prevObj, -1)
elif keyString == "Down": elif lastWasDown:
if event.detail1 == 0: if event.detail1 == 0:
msg = "WEB: First child removed. Getting new location from start of parent." msg = "WEB: First child removed. Getting new location from start of parent."
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
+12
View File
@@ -85,6 +85,7 @@ userCustomizableSettings = [
"brailleLinkIndicator", "brailleLinkIndicator",
"enableSound", "enableSound",
"soundVolume", "soundVolume",
"soundSink",
"playSoundForRole", "playSoundForRole",
"playSoundForState", "playSoundForState",
"playSoundForPositionInSet", "playSoundForPositionInSet",
@@ -333,9 +334,20 @@ brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE
ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH = "sound_and_speech" ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH = "sound_and_speech"
ROLE_SOUND_PRESENTATION_SPEECH_ONLY = "speech_only" ROLE_SOUND_PRESENTATION_SPEECH_ONLY = "speech_only"
ROLE_SOUND_PRESENTATION_SOUND_ONLY = "sound_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 enableSound = True
soundVolume = 0.5 soundVolume = 0.5
soundSink = SOUND_SINK_AUTO
playSoundForRole = False playSoundForRole = False
playSoundForState = False playSoundForState = False
playSoundForPositionInSet = False playSoundForPositionInSet = False
+496 -147
View File
@@ -31,9 +31,14 @@ __date__ = "$Date:$"
__copyright__ = "Copyright (c) 2016 Cthulhu Team" __copyright__ = "Copyright (c) 2016 Cthulhu Team"
__license__ = "LGPL" __license__ = "LGPL"
import json
import os
import subprocess
import sys
import threading
from typing import Any, Optional, Tuple
import gi import gi
from gi.repository import GLib
from typing import Optional, Any
try: try:
gi.require_version('Gst', '1.0') gi.require_version('Gst', '1.0')
@@ -41,138 +46,66 @@ try:
except Exception: except Exception:
_gstreamerAvailable: bool = False _gstreamerAvailable: bool = False
else: else:
_gstreamerAvailable, args = Gst.init_check(None) _gstreamerAvailable, _args = Gst.init_check(None)
from . import debug from . import debug
from . import settings
from . import sound_sink
from .sound_generator import Icon, Tone 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: class Player:
"""Plays Icons and Tones.""" """Plays Icons and Tones through a persistent worker process."""
def __init__(self) -> None: def __init__(self) -> None:
self._initialized: bool = False self._initialized = False
self._source: Optional[Any] = None # Optional[Gst.Element] self._workerProcess: Optional[subprocess.Popen[str]] = None
self._sink: Optional[Any] = None # Optional[Gst.Element] self._workerSink: Optional[str] = None
self._player: Optional[Any] = None # Optional[Gst.Element] self._workerRestartRequired = False
self._pipeline: Optional[Any] = None # Optional[Gst.Pipeline] self._workerRestartReason: Optional[str] = None
self._workerLock = threading.RLock()
self._responseLock = threading.Lock()
self._pendingResponses: dict[int, _PendingResponse] = {}
self._nextRequestId = 1
if not _gstreamerAvailable: if not _gstreamerAvailable:
msg = 'SOUND ERROR: Gstreamer is not available' debug.printMessage(debug.LEVEL_INFO, 'SOUND ERROR: Gstreamer is not available', True)
debug.printMessage(debug.LEVEL_INFO, msg, True)
return 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 try:
if message.type == Gst.MessageType.EOS: from . import cthulhu
self._player.set_state(Gst.State.NULL) if cthulhu.cthulhuApp is not None:
elif message.type == Gst.MessageType.ERROR: volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume')
self._player.set_state(Gst.State.NULL) return max(0.0, float(volume))
error, info = message.parse_error() except Exception:
msg = f'SOUND ERROR: {error}' pass
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _onPipelineMessage(self, bus: Any, message: Any) -> None: # bus: Gst.Bus, message: Gst.Message return max(0.0, float(settings.soundVolume))
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)
def init(self) -> None: def init(self) -> None:
"""(Re)Initializes the Player.""" """(Re)Initializes the persistent worker."""
if self._initialized:
return
if not _gstreamerAvailable: if not _gstreamerAvailable:
return return
self._player = Gst.ElementFactory.make('playbin', 'player') with self._workerLock:
if self._player is None: if self._ensureWorkerLocked():
msg = 'SOUND ERROR: Gstreamer is available, but player is None'
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
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 self._initialized = True
clearSoundSystemFailure()
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.""" """Plays a sound, interrupting the current play first unless specified."""
if isinstance(item, Icon): if isinstance(item, Icon):
@@ -180,56 +113,472 @@ class Player:
elif isinstance(item, Tone): elif isinstance(item, Tone):
self._playTone(item, interrupt) self._playTone(item, interrupt)
else: else:
tokens = ["SOUND ERROR:", item, "is not an Icon or Tone"] debug.printTokens(debug.LEVEL_INFO, ["SOUND ERROR:", item, "is not an Icon or Tone"], True)
debug.printTokens(debug.LEVEL_INFO, tokens, 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.""" """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): if isinstance(item, Icon):
return self._playIconAndWait( return self._playIconAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
item, if isinstance(item, Tone):
interrupt=interrupt, return self._playToneAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
timeout_seconds=timeout_seconds
)
self.play(item, interrupt) self.play(item, interrupt)
return False return False
def stop(self, element=None): def stop(self, _element: Any = None) -> None:
"""Stops play.""" """Stops current sound playback."""
if not _gstreamerAvailable: self._sendWorkerCommand({"action": "stop"}, waitForResponse=False)
return
if element: def shutdown(self) -> None:
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):
"""Shuts down the sound utilities.""" """Shuts down the sound utilities."""
global _gstreamerAvailable
if not _gstreamerAvailable: if not _gstreamerAvailable:
return return
self.stop() with self._workerLock:
self._stopWorkerLocked("Sound system shutdown")
self._initialized = False self._initialized = False
_gstreamerAvailable = 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() _player = Player()
def getPlayer():
def getPlayer() -> Player:
return _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 return None
def _playThemeSound(self, soundName, interrupt=True, wait=False, def _playThemeSound(self, soundName, interrupt=True, wait=False,
requireSoundSetting=False): requireSoundSetting=False, timeoutSeconds=10):
"""Play a themed sound with optional gating and blocking. """Play a themed sound with optional gating and blocking.
Args: Args:
@@ -349,9 +349,17 @@ class SoundThemeManager:
try: try:
icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath)) icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
if icon.isValid(): 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() player = sound.getPlayer()
if wait and hasattr(player, "playAndWait"):
return player.playAndWait(icon, interrupt=interrupt)
player.play(icon, interrupt=interrupt) player.play(icon, interrupt=interrupt)
return True return True
except Exception as e: except Exception as e:
@@ -360,7 +368,7 @@ class SoundThemeManager:
return False 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. """Play a sound from the current theme if enabled.
Args: Args:
@@ -374,7 +382,9 @@ class SoundThemeManager:
return self._playThemeSound( return self._playThemeSound(
soundName, soundName,
interrupt=interrupt, interrupt=interrupt,
wait=wait wait=wait,
requireSoundSetting=True,
timeoutSeconds=timeoutSeconds
) )
def playFocusModeSound(self): def playFocusModeSound(self):
@@ -389,22 +399,24 @@ class SoundThemeManager:
"""Play sound for button focus (future use).""" """Play sound for button focus (future use)."""
return self.playSound(SOUND_BUTTON) return self.playSound(SOUND_BUTTON)
def playStartSound(self, wait=False): def playStartSound(self, wait=False, timeoutSeconds=10):
"""Play sound for application startup.""" """Play sound for application startup."""
return self._playThemeSound( return self._playThemeSound(
SOUND_START, SOUND_START,
interrupt=True, interrupt=True,
wait=wait, 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.""" """Play sound for application shutdown."""
return self._playThemeSound( return self._playThemeSound(
SOUND_STOP, SOUND_STOP,
interrupt=True, interrupt=True,
wait=wait, wait=wait,
requireSoundSetting=True requireSoundSetting=True,
timeoutSeconds=timeoutSeconds
) )
_manager = None _manager = None
+118 -1
View File
@@ -82,6 +82,108 @@ def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool:
return False return False
return moduleName.split(".")[-1] == "speechdispatcherfactory" 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: def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> SpeechServer:
if not moduleName: if not moduleName:
@@ -125,9 +227,24 @@ def _refreshEchoSpeechServer() -> None:
return return
try: 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( _echoSpeechserver = _initSpeechServer(
settings.speechServerFactory, settings.speechServerFactory,
settings.echoSpeechServerInfo resolvedInfo
) )
except Exception: except Exception:
debug.printException(debug.LEVEL_INFO) debug.printException(debug.LEVEL_INFO)
+65
View File
@@ -0,0 +1,65 @@
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 gi.repository import Gdk
from cthulhu import input_event
class KeyboardEventConsumptionTests(unittest.TestCase):
def test_script_consumed_key_without_handler_is_treated_as_consumed(self):
testScript = mock.Mock()
testScript.app = None
testScript.keyBindings.getInputHandler.return_value = None
testScript.shouldConsumeKeyboardEvent.return_value = True
testScript.learnModePresenter.is_active.return_value = False
testScript.presentKeyboardEvent.return_value = False
keyboardEvent = input_event.KeyboardEvent(
True,
36,
Gdk.KEY_Return,
0,
"Return",
)
keyboardEvent.set_script(testScript)
keyboardEvent.set_object(None)
keyboardEvent.set_window(None)
with (
mock.patch("cthulhu.input_event.cthulhu_state.capturingKeys", False),
mock.patch("cthulhu.input_event.cthulhu_state.bypassNextCommand", False),
):
keyboardEvent._finalize_initialization()
self.assertTrue(keyboardEvent._should_consume)
self.assertEqual(
keyboardEvent._consume_reason,
"Script consumed without handler",
)
didConsume, resultReason = keyboardEvent._process()
self.assertTrue(didConsume)
self.assertEqual(resultReason, "Consumed during shouldConsume")
testScript.presentationInterrupt.assert_called_once()
testScript.presentKeyboardEvent.assert_called_once_with(keyboardEvent)
if __name__ == "__main__":
unittest.main()
@@ -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: Borderlands 2",
)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"username Playing: Borderlands 2",
notification,
)
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
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.scripts.apps.steamwebhelper import script as steam_script
from cthulhu.scripts.apps.steamwebhelper import script_utilities as steam_script_utilities
class SteamSelectionChangedTests(unittest.TestCase):
def test_selection_changed_tolerates_scalar_any_data(self):
testScript = steam_script.Script.__new__(steam_script.Script)
source = object()
event = mock.Mock(source=source, any_data=0)
chromiumCalls = []
def displayedText(obj):
if obj is source:
return ""
raise TypeError("argument self: Expected Atspi.Accessible, but got int")
def get_name(obj):
if obj is source:
return "Notifications"
raise TypeError("argument self: Expected Atspi.Accessible, but got int")
testScript.utilities = mock.Mock()
testScript.utilities.displayedText.side_effect = displayedText
def chromiumOnSelectionChanged(self, selectionEvent):
chromiumCalls.append((self, selectionEvent))
return True
with (
mock.patch.object(steam_script.AXObject, "get_name", side_effect=get_name),
mock.patch.object(steam_script.AXObject, "get_description", return_value=""),
mock.patch.object(steam_script.AXObject, "get_role_name", return_value="page tab list"),
mock.patch.object(steam_script.AXObject, "get_path", return_value=[1, 2, 3]),
mock.patch.object(
steam_script.Chromium.Script,
"onSelectionChanged",
new=chromiumOnSelectionChanged,
),
):
self.assertTrue(testScript.onSelectionChanged(event))
self.assertEqual(chromiumCalls, [(testScript, event)])
class SteamReturnActivationTests(unittest.TestCase):
def test_return_activates_focused_steam_button(self):
testScript = steam_script.Script.__new__(steam_script.Script)
button = object()
keyboardEvent = mock.Mock(event_string="Return", modifiers=0)
keyboardEvent.is_pressed_key.return_value = True
testScript.utilities = mock.Mock()
testScript.utilities.inDocumentContent.return_value = True
testScript.inFocusMode = mock.Mock(return_value=True)
testScript.presentMessage = mock.Mock()
testScript._presentDelayedMessage = mock.Mock()
testScript._restoreFocusAfterClick = mock.Mock()
def has_action(obj, action_name):
return obj is button and action_name == "press"
with (
mock.patch.object(steam_script.cthulhu_state, "locusOfFocus", button),
mock.patch.object(steam_script.AXUtilities, "is_entry", return_value=False),
mock.patch.object(steam_script.AXUtilities, "is_text", return_value=False),
mock.patch.object(steam_script.AXUtilities, "is_password_text", return_value=False),
mock.patch.object(steam_script.AXUtilities, "is_combo_box", return_value=False),
mock.patch.object(steam_script.AXUtilities, "is_button", return_value=True),
mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False),
mock.patch.object(steam_script.AXUtilities, "is_link", return_value=False),
mock.patch.object(steam_script.AXObject, "has_action", side_effect=has_action),
mock.patch.object(steam_script.Script, "_performClickableAction", return_value=True) as performAction,
):
self.assertTrue(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None))
performAction.assert_called_once_with(button)
class SteamLabelRecoveryTests(unittest.TestCase):
def test_displayed_label_recovers_friends_list_tab_text_from_parent_context(self):
testScript = mock.Mock(generatorCache={})
utilities = steam_script_utilities.Utilities(testScript)
button = object()
parent = object()
textSibling = object()
utilities.inDocumentContent = mock.Mock(return_value=True)
def get_attribute(obj, name):
if obj is button and name == "class":
return "FriendsListTab Active Panel Focusable gpfocus"
return None
def get_name(obj):
if obj is textSibling:
return "Friends"
return ""
def iter_children(obj, pred=None):
children = [textSibling, button]
if obj is not parent:
children = []
if pred is not None:
children = [child for child in children if pred(child)]
return iter(children)
with (
mock.patch.object(steam_script_utilities.ChromiumUtilities, "displayedLabel", return_value=""),
mock.patch.object(steam_script_utilities.AXObject, "get_parent", side_effect=lambda obj: parent if obj is button else None),
mock.patch.object(steam_script_utilities.AXObject, "get_attribute", side_effect=get_attribute),
mock.patch.object(steam_script_utilities.AXObject, "get_name", side_effect=get_name),
mock.patch.object(steam_script_utilities.AXObject, "supports_text", return_value=False),
mock.patch.object(steam_script_utilities.AXObject, "iter_children", side_effect=iter_children),
mock.patch.object(steam_script_utilities.AXUtilities, "is_button", side_effect=lambda obj: obj is button),
mock.patch.object(steam_script_utilities.AXUtilities, "is_push_button", return_value=False),
):
self.assertEqual(utilities.displayedLabel(button), "Friends")
def test_displayed_label_maps_add_friend_button_class_to_fallback_name(self):
testScript = mock.Mock(generatorCache={})
utilities = steam_script_utilities.Utilities(testScript)
button = object()
utilities.inDocumentContent = mock.Mock(return_value=True)
def get_attribute(obj, name):
if obj is button and name == "class":
return "friendListButton AddFriendButton Panel Focusable"
return None
with (
mock.patch.object(steam_script_utilities.ChromiumUtilities, "displayedLabel", return_value=""),
mock.patch.object(steam_script_utilities.AXObject, "get_attribute", side_effect=get_attribute),
mock.patch.object(steam_script_utilities.AXObject, "get_name", return_value=""),
mock.patch.object(steam_script_utilities.AXUtilities, "is_button", side_effect=lambda obj: obj is button),
mock.patch.object(steam_script_utilities.AXUtilities, "is_push_button", return_value=False),
):
self.assertEqual(utilities.displayedLabel(button), "Add Friend")
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
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))
class WebRemovedChildRegressionTests(unittest.TestCase):
def test_removed_child_recovery_does_not_crash_when_last_key_is_not_up_or_down(self):
utilities = script_utilities.Utilities.__new__(script_utilities.Utilities)
removedChild = object()
locusOfFocus = object()
source = object()
recoveredObject = object()
event = mock.Mock(any_data=removedChild, source=source, detail1=0)
utilities._script = mock.Mock(pointOfReference={"names": {}})
utilities._handleEventForRemovedListBoxChild = mock.Mock(return_value=False)
utilities.isSameObject = mock.Mock(return_value=False)
utilities.searchForCaretContext = mock.Mock(return_value=(recoveredObject, 0))
utilities.setCaretContext = mock.Mock()
manager = mock.Mock()
manager.last_event_was_up.return_value = False
manager.last_event_was_down.return_value = False
def find_ancestor(obj, predicate):
if obj is locusOfFocus and predicate(removedChild):
return removedChild
return None
with (
mock.patch.object(script_utilities.cthulhu_state, "locusOfFocus", locusOfFocus),
mock.patch.object(script_utilities.input_event_manager, "get_manager", return_value=manager),
mock.patch.object(script_utilities.AXObject, "find_ancestor", side_effect=find_ancestor),
mock.patch.object(script_utilities.AXObject, "get_child_count", return_value=0),
mock.patch.object(script_utilities.AXObject, "clear_cache"),
mock.patch.object(script_utilities.AXObject, "is_dead", return_value=False),
mock.patch.object(script_utilities.AXUtilities, "get_focused_object", return_value=None),
mock.patch.object(script_utilities.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
self.assertTrue(utilities.handleEventForRemovedChild(event))
setLocusOfFocus.assert_called_once_with(event, recoveredObject, False)
utilities.setCaretContext.assert_called_once_with(recoveredObject, 0)
if __name__ == "__main__":
unittest.main()