Fix script manager resets and isolate startup sound

This commit is contained in:
Storm Dragon
2026-03-23 12:44:12 -04:00
parent a7cd1d033a
commit 438fae4fef
7 changed files with 208 additions and 10 deletions
+8
View File
@@ -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)
+36 -5
View File
@@ -277,6 +277,23 @@ class InputEventManager:
self._last_input_event = event
self._last_non_modifier_key_event = None
@staticmethod
def _get_top_level_window(obj: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]:
"""Returns the top-level window containing obj, if one can be found."""
if obj is None:
return None
if AXUtilities.is_frame(obj) or AXUtilities.is_window(obj) or AXUtilities.is_dialog_or_alert(obj):
return obj
return AXObject.find_ancestor(
obj,
lambda x: AXUtilities.is_frame(x)
or AXUtilities.is_window(x)
or AXUtilities.is_dialog_or_alert(x),
)
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
def process_keyboard_event(
@@ -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())
+1
View File
@@ -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',
+1 -1
View File
@@ -473,5 +473,5 @@ def get_manager() -> Optional[ScriptManager]:
if _manager is None:
from . import cthulhu
if cthulhu.cthulhuApp:
_manager = ScriptManager(cthulhu.cthulhuApp)
_manager = cthulhu.cthulhuApp.scriptManager
return _manager
+79 -1
View File
@@ -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)
+74
View File
@@ -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())
+9 -3
View File
@@ -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):