From 438fae4fef1a03b34dbeecc5a1629d333956a982 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 23 Mar 2026 12:44:12 -0400 Subject: [PATCH] Fix script manager resets and isolate startup sound --- src/cthulhu/cthulhu.py | 8 +++ src/cthulhu/input_event_manager.py | 41 +++++++++++++-- src/cthulhu/meson.build | 1 + src/cthulhu/script_manager.py | 2 +- src/cthulhu/sound.py | 80 +++++++++++++++++++++++++++++- src/cthulhu/sound_helper.py | 74 +++++++++++++++++++++++++++ src/cthulhu/sound_theme_manager.py | 12 +++-- 7 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 src/cthulhu/sound_helper.py diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py index a5122a3..70a91aa 100644 --- a/src/cthulhu/cthulhu.py +++ b/src/cthulhu/cthulhu.py @@ -894,6 +894,13 @@ def main() -> int: script = cthulhu_state.activeScript sound_theme_manager.getManager().playStartSound(wait=True) + soundFailureReason = sound.getSoundSystemFailureReason() + if soundFailureReason: + debug.printMessage( + debug.LEVEL_INFO, + f"CTHULHU: Startup sound failed. Continuing without sound. Reason: {soundFailureReason}", + True + ) cthulhuApp.getSignalManager().emitSignal('start-application-completed') if script: window = script.utilities.activeWindow() @@ -944,6 +951,7 @@ class Cthulhu(GObject.Object): self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate self.scriptManager: ScriptManager = script_manager.ScriptManager(self) # Directly instantiate + script_manager._manager = self.scriptManager self.logger: logger.Logger = logger.Logger() # Directly instantiate self.signalManager: SignalManager = signal_manager.SignalManager(self) self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self) diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index e816655..8a4711c 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -277,6 +277,23 @@ class InputEventManager: self._last_input_event = event self._last_non_modifier_key_event = None + @staticmethod + def _get_top_level_window(obj: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]: + """Returns the top-level window containing obj, if one can be found.""" + + if obj is None: + return None + + if AXUtilities.is_frame(obj) or AXUtilities.is_window(obj) or AXUtilities.is_dialog_or_alert(obj): + return obj + + return AXObject.find_ancestor( + obj, + lambda x: AXUtilities.is_frame(x) + or AXUtilities.is_window(x) + or AXUtilities.is_dialog_or_alert(x), + ) + # pylint: disable=too-many-arguments # pylint: disable=too-many-positional-arguments def process_keyboard_event( @@ -320,11 +337,25 @@ class InputEventManager: debug.print_tokens(debug.LEVEL_INFO, tokens, True) manager.set_active_window(window) else: - # One example: Brave's popup menus live in frames which lack the active state. - tokens = ["WARNING:", window, "cannot be active window. No alternative found."] - debug.print_tokens(debug.LEVEL_WARNING, tokens, True) - window = None - manager.set_active_window(None, notify_script=True) + focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus()) + if focus_window is not None: + window = focus_window + tokens = [ + "INPUT EVENT MANAGER: Recovering active window from locus of focus:", + window, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + manager.set_active_window(window) + else: + # One example: Brave's popup menus live in frames which lack the active + # state. Failing to revalidate the window on a key press is inconclusive; + # do not wipe out the last known window and focus state. + tokens = [ + "WARNING:", + window, + "cannot be confirmed as active. No alternative found; preserving existing context.", + ] + debug.print_tokens(debug.LEVEL_WARNING, tokens, True) event.set_window(window) event.set_object(pendingFocus or manager.get_locus_of_focus()) event.set_script(script_manager.get_manager().get_active_script()) diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index e1d13b1..059ccc3 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -86,6 +86,7 @@ cthulhu_python_sources = files([ 'signal_manager.py', 'sleep_mode_manager.py', 'sound.py', + 'sound_helper.py', 'sound_generator.py', 'sound_theme_manager.py', 'speech_and_verbosity_manager.py', diff --git a/src/cthulhu/script_manager.py b/src/cthulhu/script_manager.py index 8855d24..da29416 100644 --- a/src/cthulhu/script_manager.py +++ b/src/cthulhu/script_manager.py @@ -473,5 +473,5 @@ def get_manager() -> Optional[ScriptManager]: if _manager is None: from . import cthulhu if cthulhu.cthulhuApp: - _manager = ScriptManager(cthulhu.cthulhuApp) + _manager = cthulhu.cthulhuApp.scriptManager return _manager diff --git a/src/cthulhu/sound.py b/src/cthulhu/sound.py index 3114799..f0fe7f1 100644 --- a/src/cthulhu/sound.py +++ b/src/cthulhu/sound.py @@ -31,9 +31,13 @@ __date__ = "$Date:$" __copyright__ = "Copyright (c) 2016 Cthulhu Team" __license__ = "LGPL" +import os +import subprocess +import sys + import gi from gi.repository import GLib -from typing import Optional, Any +from typing import Optional, Any, Tuple try: gi.require_version('Gst', '1.0') @@ -46,6 +50,8 @@ else: from . import debug from .sound_generator import Icon, Tone +_soundSystemFailureReason: Optional[str] = None + class Player: """Plays Icons and Tones.""" @@ -233,3 +239,75 @@ _player = Player() def getPlayer(): return _player + +def disableSoundSystem(reason: str) -> None: + global _soundSystemFailureReason + + if _soundSystemFailureReason == reason: + return + + _soundSystemFailureReason = reason + msg = f"SOUND: Disabling sound system. Reason: {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + +def getSoundSystemFailureReason() -> Optional[str]: + return _soundSystemFailureReason + +def isSoundSystemAvailable() -> bool: + return _soundSystemFailureReason is None + +def _playIconInSubprocess(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]: + if not icon.isValid(): + return False, f"Invalid sound icon: {icon.path}" + + 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) + + command = [ + sys.executable, + "-m", + "cthulhu.sound_helper", + "--play-file", + icon.path, + "--timeout-seconds", + str(timeoutSeconds), + ] + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeoutSeconds + 2, + check=False, + env=environment, + ) + except subprocess.TimeoutExpired: + return False, f"Sound probe timed out after {timeoutSeconds} seconds" + except Exception as error: + return False, f"Sound probe failed to launch: {error}" + + if result.returncode == 0: + return True, None + + if result.returncode < 0: + reason = f"Sound probe exited via signal {-result.returncode}" + else: + reason = f"Sound probe exited with status {result.returncode}" + + stderr = (result.stderr or "").strip() + if stderr: + reason = f"{reason}: {stderr.splitlines()[0]}" + + return False, reason + +def playIconSafely(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]: + return _playIconInSubprocess(icon, timeoutSeconds) diff --git a/src/cthulhu/sound_helper.py b/src/cthulhu/sound_helper.py new file mode 100644 index 0000000..022e5b6 --- /dev/null +++ b/src/cthulhu/sound_helper.py @@ -0,0 +1,74 @@ +#!/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. + +"""Minimal sound playback helper used to isolate startup sound crashes.""" + +from __future__ import annotations + +import argparse +import pathlib +import sys + +import gi + +gi.require_version('Gst', '1.0') +from gi.repository import Gst + + +def play_file(soundPath: str, timeoutSeconds: int = 10) -> int: + available, _args = Gst.init_check(None) + if not available: + return 1 + + player = Gst.ElementFactory.make('playbin', 'cthulhu-sound-helper') + if player is None: + return 1 + + player.set_property('uri', pathlib.Path(soundPath).resolve().as_uri()) + player.set_state(Gst.State.PLAYING) + + bus = player.get_bus() + if bus is None: + player.set_state(Gst.State.NULL) + return 1 + + timeoutNs = int(timeoutSeconds * Gst.SECOND) + try: + message = bus.timed_pop_filtered( + timeoutNs, + Gst.MessageType.EOS | Gst.MessageType.ERROR + ) + if message is None: + return 1 + if message.type == Gst.MessageType.ERROR: + return 1 + return 0 + finally: + player.set_state(Gst.State.NULL) + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--play-file") + parser.add_argument("--timeout-seconds", type=int, default=10) + args, _unknown = parser.parse_known_args() + + if args.play_file: + return play_file(args.play_file, args.timeout_seconds) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cthulhu/sound_theme_manager.py b/src/cthulhu/sound_theme_manager.py index 36941e0..8e95c06 100644 --- a/src/cthulhu/sound_theme_manager.py +++ b/src/cthulhu/sound_theme_manager.py @@ -349,9 +349,14 @@ class SoundThemeManager: try: icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath)) if icon.isValid(): + if wait: + success, reason = sound.playIconSafely(icon) + if not success: + if reason: + sound.disableSoundSystem(reason) + self.app.getSettingsManager().setSetting('enableSound', False) + return success player = sound.getPlayer() - if wait and hasattr(player, "playAndWait"): - return player.playAndWait(icon, interrupt=interrupt) player.play(icon, interrupt=interrupt) return True except Exception as e: @@ -374,7 +379,8 @@ class SoundThemeManager: return self._playThemeSound( soundName, interrupt=interrupt, - wait=wait + wait=wait, + requireSoundSetting=True ) def playFocusModeSound(self):