Fix script manager resets and isolate startup sound
This commit is contained in:
@@ -894,6 +894,13 @@ def main() -> int:
|
|||||||
|
|
||||||
script = cthulhu_state.activeScript
|
script = cthulhu_state.activeScript
|
||||||
sound_theme_manager.getManager().playStartSound(wait=True)
|
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')
|
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)
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -320,11 +337,25 @@ 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:
|
||||||
debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
|
window = focus_window
|
||||||
window = None
|
tokens = [
|
||||||
manager.set_active_window(None, notify_script=True)
|
"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_window(window)
|
||||||
event.set_object(pendingFocus or 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())
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ 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_generator.py',
|
'sound_generator.py',
|
||||||
'sound_theme_manager.py',
|
'sound_theme_manager.py',
|
||||||
'speech_and_verbosity_manager.py',
|
'speech_and_verbosity_manager.py',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+79
-1
@@ -31,9 +31,13 @@ __date__ = "$Date:$"
|
|||||||
__copyright__ = "Copyright (c) 2016 Cthulhu Team"
|
__copyright__ = "Copyright (c) 2016 Cthulhu Team"
|
||||||
__license__ = "LGPL"
|
__license__ = "LGPL"
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, Tuple
|
||||||
|
|
||||||
try:
|
try:
|
||||||
gi.require_version('Gst', '1.0')
|
gi.require_version('Gst', '1.0')
|
||||||
@@ -46,6 +50,8 @@ else:
|
|||||||
from . import debug
|
from . import debug
|
||||||
from .sound_generator import Icon, Tone
|
from .sound_generator import Icon, Tone
|
||||||
|
|
||||||
|
_soundSystemFailureReason: Optional[str] = None
|
||||||
|
|
||||||
class Player:
|
class Player:
|
||||||
"""Plays Icons and Tones."""
|
"""Plays Icons and Tones."""
|
||||||
|
|
||||||
@@ -233,3 +239,75 @@ _player = Player()
|
|||||||
|
|
||||||
def getPlayer():
|
def getPlayer():
|
||||||
return _player
|
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)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -349,9 +349,14 @@ 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)
|
||||||
|
if not success:
|
||||||
|
if reason:
|
||||||
|
sound.disableSoundSystem(reason)
|
||||||
|
self.app.getSettingsManager().setSetting('enableSound', False)
|
||||||
|
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:
|
||||||
@@ -374,7 +379,8 @@ class SoundThemeManager:
|
|||||||
return self._playThemeSound(
|
return self._playThemeSound(
|
||||||
soundName,
|
soundName,
|
||||||
interrupt=interrupt,
|
interrupt=interrupt,
|
||||||
wait=wait
|
wait=wait,
|
||||||
|
requireSoundSetting=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def playFocusModeSound(self):
|
def playFocusModeSound(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user