6 Commits

Author SHA1 Message Date
Storm Dragon 394d814ad0 updated PKGBUILDs. 2026-07-18 14:24:42 -04:00
Storm Dragon 006612c1da Merge branch 'testing' 2026-07-18 02:55:04 -04:00
Storm Dragon 49f0d3c612 Tests for the new plugins dialogue updates. 2026-07-18 02:54:53 -04:00
Storm Dragon 8e496ac606 Redesigned the plugins interface. 2026-07-18 00:19:08 -04:00
Storm Dragon 0576b6f79f Release candidate. 2026-07-17 13:39:18 -04:00
Storm Dragon 40aeadb68a Tighter integration with i38lock. Found and squished a bug with sound sometimes not loading. 2026-07-17 13:38:01 -04:00
21 changed files with 797 additions and 499 deletions
+21 -44
View File
@@ -1,23 +1,24 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
pkgname=cthulhu-git pkgname=cthulhu-git
_pkgname=cthulhu _pkgname=cthulhu
pkgver=2026.05.25.r407.gc39f231 pkgver=2026.05.25.r407.gc39f231
pkgrel=1 pkgrel=2
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"
arch=(any) arch=(any)
license=(LGPL) license=(LGPL-2.1-or-later)
provides=("${_pkgname}") provides=("${_pkgname}=${pkgver}")
conflicts=("${_pkgname}") conflicts=("${_pkgname}")
depends=( depends=(
# Core AT-SPI accessibility # Core AT-SPI accessibility
at-spi2-core at-spi2-core
glib2
gobject-introspection-runtime gobject-introspection-runtime
python-gobject python-gobject
python-cairo python-cairo
gtk3 gtk3
python-pywayland
# Audio and speech # Audio and speech
speech-dispatcher speech-dispatcher
gstreamer gstreamer
@@ -25,75 +26,51 @@ depends=(
gst-plugins-good # pulsesink, more decoders gst-plugins-good # pulsesink, more decoders
# Braille support # Braille support
brltty python-brltty
liblouis liblouis
# Plugin system and D-Bus remote control # Plugin system and D-Bus remote control
python-pluggy python-pluggy
python-tomlkit python-tomlkit
python-dasbus python-dasbus
python-msgpack
# AI Assistant dependencies (for screenshots, HTTP requests, and actions) python-tornado
python-requests python-xlib
python-pyautogui
# Desktop integration # Desktop integration
hicolor-icon-theme hicolor-icon-theme
libwnck3 libwnck3
pango pango
# System utilities # System utilities
python
python-setproctitle python-setproctitle
socat # for self-voicing feature
xorg-xkbcomp xorg-xkbcomp
xorg-xmodmap
) )
optdepends=( optdepends=(
'espeak-ng: Alternative TTS engine' 'git: Install and update third-party plugin sources'
'festival: Alternative TTS engine' 'python-pywayland: Wayland shared workspace backend'
'flite: Lightweight TTS engine' 'piper-tts: Piper speech backend'
'espeak: Legacy TTS engine' 'socat: Send text to the Self Voice socket from the command line'
# AI Assistant providers (optional) # AI Assistant providers (optional)
'python-requests: AI Assistant plugin and HTTP provider support'
'python-pyautogui: AI Assistant mouse and keyboard actions'
'claude-code: Claude AI provider support' 'claude-code: Claude AI provider support'
'openai-codex: ChatGPT AI provider support' 'openai-codex: OpenAI Codex provider support'
'gemini-cli: Gemini AI provider support' 'gemini-cli: Gemini AI provider support'
'ollama: Local AI model support' 'ollama: Local AI model support'
# OCR plugin dependencies (optional) # OCR plugin dependencies (optional)
'python-pillow: Image processing for OCR and AI Assistant' 'python-pillow: Image processing for OCR'
'python-pytesseract: Python wrapper for Tesseract OCR engine' 'python-pytesseract: Python wrapper for Tesseract OCR engine'
'python-pdf2image: PDF to image conversion for OCR'
'python-scipy: Scientific computing for OCR color analysis'
'python-webcolors: Color name lookup for OCR text decoration'
'tesseract: OCR engine for text recognition' 'tesseract: OCR engine for text recognition'
'tesseract-data-eng: English language data for Tesseract'
# nvda2cthulhu plugin (optional)
'python-msgpack: Msgpack decoding for nvda2cthulhu'
'python-tornado: WebSocket server for nvda2cthulhu'
# Window Title Reader plugin (optional)
'python-xlib: X11 access for Wine window title plugin'
) )
makedepends=( makedepends=(
git git
meson meson
ninja
python-build
python-installer
python-wheel
)
install=cthulhu.install
source=(
"${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master"
"cthulhu.install"
)
b2sums=(
'SKIP'
'SKIP'
) )
source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master")
sha512sums=('SKIP')
pkgver() { pkgver() {
cd "${_pkgname}" cd "${_pkgname}"
@@ -1,11 +0,0 @@
post_install() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
post_upgrade() {
post_install
}
post_remove() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
+26 -49
View File
@@ -1,96 +1,73 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
pkgname=cthulhu pkgname=cthulhu
pkgver=2026.05.25 pkgver=2026.05.25
pkgrel=1 pkgrel=2
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"
arch=(any) arch=(any)
license=(LGPL) license=(LGPL-2.1-or-later)
depends=( depends=(
# Core AT-SPI accessibility # Core AT-SPI accessibility
at-spi2-core at-spi2-core
glib2
gobject-introspection-runtime gobject-introspection-runtime
python-gobject python-gobject
python-cairo python-cairo
gtk3 gtk3
python-pywayland
# Audio and speech # Audio and speech
speech-dispatcher speech-dispatcher
gstreamer gstreamer
gst-plugins-base # playbin, audiotestsrc, basic decoders gst-plugins-base # playbin, audiotestsrc, basic decoders
gst-plugins-good # pulsesink, more decoders gst-plugins-good # pulsesink, more decoders
# Braille support # Braille support
brltty python-brltty
liblouis liblouis
# Plugin system and D-Bus remote control # Plugin system and D-Bus remote control
python-pluggy python-pluggy
python-tomlkit python-tomlkit
python-dasbus python-dasbus
python-msgpack
# AI Assistant dependencies (for screenshots, HTTP requests, and actions) python-tornado
python-requests python-xlib
python-pyautogui
# Desktop integration # Desktop integration
hicolor-icon-theme hicolor-icon-theme
libwnck3 libwnck3
pango pango
# System utilities # System utilities
python
python-setproctitle python-setproctitle
socat # for self-voicing feature
xorg-xkbcomp xorg-xkbcomp
xorg-xmodmap
) )
optdepends=( optdepends=(
'espeak-ng: Alternative TTS engine' 'git: Install and update third-party plugin sources'
'festival: Alternative TTS engine' 'python-pywayland: Wayland shared workspace backend'
'flite: Lightweight TTS engine' 'piper-tts: Piper speech backend'
'espeak: Legacy TTS engine' 'socat: Send text to the Self Voice socket from the command line'
# AI Assistant providers (optional) # AI Assistant providers (optional)
'python-requests: AI Assistant plugin and HTTP provider support'
'python-pyautogui: AI Assistant mouse and keyboard actions'
'claude-code: Claude AI provider support' 'claude-code: Claude AI provider support'
'openai-codex: ChatGPT AI provider support' 'openai-codex: OpenAI Codex provider support'
'gemini-cli: Gemini AI provider support' 'gemini-cli: Gemini AI provider support'
'ollama: Local AI model support' 'ollama: Local AI model support'
# OCR plugin dependencies (optional)
'python-pillow: Image processing for OCR and AI Assistant'
'python-pytesseract: Python wrapper for Tesseract OCR engine'
'python-pdf2image: PDF to image conversion for OCR'
'python-scipy: Scientific computing for OCR color analysis'
'python-webcolors: Color name lookup for OCR text decoration'
'tesseract: OCR engine for text recognition'
'tesseract-data-eng: English language data for Tesseract'
# nvda2cthulhu plugin (optional)
'python-msgpack: Msgpack decoding for nvda2cthulhu'
'python-tornado: WebSocket server for nvda2cthulhu'
# Window Title Reader plugin (optional) # OCR plugin dependencies (optional)
'python-xlib: X11 access for Wine window title plugin' 'python-pillow: Image processing for OCR'
'python-pytesseract: Python wrapper for Tesseract OCR engine'
'tesseract: OCR engine for text recognition'
) )
makedepends=( makedepends=(
git git
meson meson
ninja
python-build
python-installer
python-wheel
)
install=cthulhu.install
source=(
"git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}"
"cthulhu.install"
)
b2sums=(
'SKIP'
'SKIP'
) )
source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}")
sha512sums=('SKIP')
build() { build() {
cd cthulhu cd cthulhu
@@ -1,11 +0,0 @@
post_install() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
post_upgrade() {
post_install
}
post_remove() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
-4
View File
@@ -56,12 +56,8 @@ optional_modules = {
'louis': 'contracted braille', 'louis': 'contracted braille',
'speechd': 'speech output', 'speechd': 'speech output',
'dasbus': 'D-Bus remote controller', 'dasbus': 'D-Bus remote controller',
'psutil': 'system information commands',
'pywayland': 'Wayland shared workspace backend', 'pywayland': 'Wayland shared workspace backend',
'gi.repository.Wnck': 'mouse review', 'gi.repository.Wnck': 'mouse review',
'pdf2image': 'PDF processing for OCR',
'scipy': 'Scientific computing for OCR analysis',
'webcolors': 'Color name resolution for OCR',
} }
summary = {} summary = {}
+13 -1
View File
@@ -257,6 +257,7 @@ from . import keybindings
from . import learn_mode_presenter from . import learn_mode_presenter
from . import logger from . import logger
from . import mako_notification_monitor from . import mako_notification_monitor
from . import screen_lock_monitor
from . import messages from . import messages
from . import notification_presenter from . import notification_presenter
from . import focus_manager from . import focus_manager
@@ -724,6 +725,7 @@ def start() -> None:
# Start D-Bus remote controller service after ATSPI is ready # Start D-Bus remote controller service after ATSPI is ready
with debug.startup_timing("startup idle services"): with debug.startup_timing("startup idle services"):
cthulhuApp.getMakoNotificationMonitor().start() cthulhuApp.getMakoNotificationMonitor().start()
cthulhuApp.getScreenLockMonitor().start()
GObject.idle_add(_start_dbus_service) GObject.idle_add(_start_dbus_service)
debug.print_startup_timing("AT-SPI main loop start") debug.print_startup_timing("AT-SPI main loop start")
@@ -772,11 +774,13 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) ->
signal.signal(signal.SIGALRM, settings.timeoutCallback) signal.signal(signal.SIGALRM, settings.timeoutCallback)
signal.alarm(settings.timeoutTime) signal.alarm(settings.timeoutTime)
cthulhu_state.activeScript.presentationInterrupt() if cthulhu_state.activeScript is not None:
cthulhu_state.activeScript.presentationInterrupt()
cthulhuApp.getSignalManager().emitSignal('stop-application-completed') cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1) sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1)
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True) cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
cthulhuApp.getScreenLockMonitor().stop()
cthulhuApp.getMakoNotificationMonitor().stop() cthulhuApp.getMakoNotificationMonitor().stop()
# 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
@@ -991,6 +995,9 @@ class Cthulhu(GObject.Object):
with debug.startup_timing("notification monitor construction"): with debug.startup_timing("notification monitor construction"):
self.makoNotificationMonitor: mako_notification_monitor.MakoNotificationMonitor = \ self.makoNotificationMonitor: mako_notification_monitor.MakoNotificationMonitor = \
mako_notification_monitor.MakoNotificationMonitor(self) mako_notification_monitor.MakoNotificationMonitor(self)
with debug.startup_timing("screen lock monitor construction"):
self.screenLockMonitor: screen_lock_monitor.ScreenLockMonitor = \
screen_lock_monitor.ScreenLockMonitor(self)
with debug.startup_timing("compat API registration"): with debug.startup_timing("compat API registration"):
self.createCompatAPI() self.createCompatAPI()
with debug.startup_timing("plugin manager construction"): with debug.startup_timing("plugin manager construction"):
@@ -1009,6 +1016,9 @@ class Cthulhu(GObject.Object):
def getMakoNotificationMonitor(self) -> mako_notification_monitor.MakoNotificationMonitor: def getMakoNotificationMonitor(self) -> mako_notification_monitor.MakoNotificationMonitor:
return self.makoNotificationMonitor return self.makoNotificationMonitor
def getScreenLockMonitor(self) -> screen_lock_monitor.ScreenLockMonitor:
return self.screenLockMonitor
def getDynamicApiManager(self) -> DynamicApiManager: def getDynamicApiManager(self) -> DynamicApiManager:
return self.dynamicApiManager return self.dynamicApiManager
@@ -1061,6 +1071,7 @@ class Cthulhu(GObject.Object):
"speech_server_present": speechServer is not None, "speech_server_present": speechServer is not None,
"speech_server": speechServer.__class__.__name__ if speechServer else "", "speech_server": speechServer.__class__.__name__ if speechServer else "",
}, },
"sound": sound.get_debug_snapshot(),
"event_manager": self.eventManager.get_debug_snapshot(), "event_manager": self.eventManager.get_debug_snapshot(),
"script_manager": self.scriptManager.get_debug_snapshot(), "script_manager": self.scriptManager.get_debug_snapshot(),
"input_event_manager": input_event_manager.get_manager().get_debug_snapshot(), "input_event_manager": input_event_manager.get_manager().get_debug_snapshot(),
@@ -1097,6 +1108,7 @@ class Cthulhu(GObject.Object):
self.getDynamicApiManager().registerAPI('Cmdnames', cmdnames) self.getDynamicApiManager().registerAPI('Cmdnames', cmdnames)
self.getDynamicApiManager().registerAPI('NotificationPresenter', notification_presenter) self.getDynamicApiManager().registerAPI('NotificationPresenter', notification_presenter)
self.getDynamicApiManager().registerAPI('MakoNotificationMonitor', self.makoNotificationMonitor) self.getDynamicApiManager().registerAPI('MakoNotificationMonitor', self.makoNotificationMonitor)
self.getDynamicApiManager().registerAPI('ScreenLockMonitor', self.screenLockMonitor)
self.getDynamicApiManager().registerAPI('CthulhuState', cthulhu_state) self.getDynamicApiManager().registerAPI('CthulhuState', cthulhu_state)
self.getDynamicApiManager().registerAPI('CthulhuPlatform', cthulhu_platform) self.getDynamicApiManager().registerAPI('CthulhuPlatform', cthulhu_platform)
self.getDynamicApiManager().registerAPI('Settings', settings) self.getDynamicApiManager().registerAPI('Settings', settings)
+1 -1
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.05.25" version = "2026.07.18"
codeName = "master" codeName = "master"
+129 -350
View File
@@ -111,11 +111,6 @@ if louis and not tablesdir:
DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16)) DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16))
class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
PLUGIN_COL_ENABLED = 0
PLUGIN_COL_DISPLAY = 1
PLUGIN_COL_CAN_TOGGLE = 2
PLUGIN_COL_NAME = 3
def __init__(self, fileName, windowName, prefsDict): def __init__(self, fileName, windowName, prefsDict):
"""Initialize the Cthulhu configuration GUI. """Initialize the Cthulhu configuration GUI.
@@ -144,22 +139,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.planeCellRendererText = None self.planeCellRendererText = None
self.pronunciationModel = None self.pronunciationModel = None
self.pronunciationView = None self.pronunciationView = None
self._plugin_treeview = None self._plugin_combo = None
self._plugin_model = None self._plugin_model = None
self._plugin_iters = {} self._plugin_entries = {}
self._plugin_enabled_iter = None self._plugin_enabled_button = None
self._plugin_disabled_iter = None self._plugin_disabled_button = None
self._plugin_sources = [] self._plugin_description_view = None
self._plugin_sources_entry = None self._updating_plugin_controls = False
self._plugin_sources_listbox = None
self._plugin_sources_original = []
self._available_plugins = set() self._available_plugins = set()
self._plugin_canonical_map = {} self._plugin_canonical_map = {}
self._plugin_group_map = {} self._plugin_group_map = {}
self._plugin_update_button = None
self._plugin_update_progress = None
self._plugin_update_status = None
self._plugin_update_in_progress = False
self._plugin_tabs = {} self._plugin_tabs = {}
self._plugin_tabs_cached = False self._plugin_tabs_cached = False
self._dynamic_plugin_tabs = {} self._dynamic_plugin_tabs = {}
@@ -440,148 +429,80 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self._initGUIState() self._initGUIState()
self._initSoundThemeState() self._initSoundThemeState()
def _initPluginsPage(self): def _initPluginsPage(self) -> None:
self._plugin_sources = list(self.prefsDict.get("pluginSources", settings.pluginSources) or [])
self._plugin_sources_original = list(self._plugin_sources)
pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
pluginsPage.set_border_width(12) pluginsPage.set_border_width(12)
infoLabel = Gtk.Label(label="Enable or disable plugins and manage plugin sources.") infoLabel = Gtk.Label(label="Select a plugin, then choose whether it is enabled or disabled.")
infoLabel.set_line_wrap(True) infoLabel.set_line_wrap(True)
infoLabel.set_halign(Gtk.Align.START) infoLabel.set_halign(Gtk.Align.START)
pluginsPage.pack_start(infoLabel, False, False, 0) pluginsPage.pack_start(infoLabel, False, False, 0)
pluginsFrame = Gtk.Frame(label="Plugins") pluginGrid = Gtk.Grid(row_spacing=10, column_spacing=10)
try: pluginLabel = Gtk.Label(label="_Plugin:")
pluginsFrame.set_label_align(0.0, 0.5) pluginLabel.set_use_underline(True)
except Exception: pluginLabel.set_halign(Gtk.Align.START)
try: self._plugin_model = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_STRING)
pluginsFrame.set_label_xalign(0.0) self._plugin_combo = Gtk.ComboBox.new_with_model(self._plugin_model)
except Exception:
pass
pluginsFrame.set_shadow_type(Gtk.ShadowType.NONE)
pluginsFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
pluginsFrameBox.set_border_width(6)
pluginsFrame.add(pluginsFrameBox)
pluginsScrolled = Gtk.ScrolledWindow()
pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
pluginsScrolled.set_size_request(-1, 200)
self._plugin_model = Gtk.TreeStore(
GObject.TYPE_BOOLEAN, # enabled
GObject.TYPE_STRING, # display text
GObject.TYPE_BOOLEAN, # can toggle
GObject.TYPE_STRING, # plugin name
)
self._plugin_treeview = Gtk.TreeView(model=self._plugin_model)
self._plugin_treeview.set_headers_visible(True)
self._plugin_treeview.set_enable_search(False)
self._plugin_treeview.connect("key-press-event", self._on_plugin_tree_key_press)
self._plugin_treeview.connect("row-activated", self._on_plugin_tree_row_activated)
toggle_renderer = Gtk.CellRendererToggle()
toggle_renderer.set_activatable(True)
toggle_renderer.connect("toggled", self._on_plugin_tree_toggled)
toggle_column = Gtk.TreeViewColumn(
"Enabled",
toggle_renderer,
active=self.PLUGIN_COL_ENABLED,
activatable=self.PLUGIN_COL_CAN_TOGGLE
)
toggle_column.add_attribute(toggle_renderer, "visible", self.PLUGIN_COL_CAN_TOGGLE)
self._plugin_treeview.append_column(toggle_column)
text_renderer = Gtk.CellRendererText() text_renderer = Gtk.CellRendererText()
text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) self._plugin_combo.pack_start(text_renderer, True)
text_column = Gtk.TreeViewColumn( self._plugin_combo.add_attribute(text_renderer, "text", 0)
"Plugin", self._plugin_combo.set_hexpand(True)
text_renderer, self._plugin_combo.connect("changed", self._on_plugin_selection_changed)
text=self.PLUGIN_COL_DISPLAY pluginLabel.set_mnemonic_widget(self._plugin_combo)
pluginGrid.attach(pluginLabel, 0, 0, 1, 1)
pluginGrid.attach(self._plugin_combo, 1, 0, 1, 1)
pluginsPage.pack_start(pluginGrid, False, False, 0)
stateFrame = Gtk.Frame(label="State")
stateFrame.set_shadow_type(Gtk.ShadowType.NONE)
stateBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
stateBox.set_border_width(6)
self._plugin_enabled_button = Gtk.RadioButton.new_with_mnemonic(None, "_Enabled")
self._plugin_disabled_button = Gtk.RadioButton.new_with_mnemonic_from_widget(
self._plugin_enabled_button,
"_Disabled",
) )
text_column.set_expand(True) self._plugin_enabled_button.connect("toggled", self._on_plugin_enabled_toggled)
self._plugin_treeview.append_column(text_column) self._plugin_disabled_button.connect("toggled", self._on_plugin_disabled_toggled)
stateBox.pack_start(self._plugin_enabled_button, False, False, 0)
stateBox.pack_start(self._plugin_disabled_button, False, False, 0)
stateFrame.add(stateBox)
pluginsPage.pack_start(stateFrame, False, False, 0)
pluginsScrolled.add(self._plugin_treeview) descriptionLabel = Gtk.Label(label="_Description:")
pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) descriptionLabel.set_use_underline(True)
descriptionLabel.set_halign(Gtk.Align.START)
pluginsPage.pack_start(descriptionLabel, False, False, 0)
pluginsPage.pack_start(pluginsFrame, True, True, 0) descriptionScrolled = Gtk.ScrolledWindow()
descriptionScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
sourcesFrame = Gtk.Frame(label="Plugin Sources") descriptionScrolled.set_size_request(-1, 180)
try: self._plugin_description_view = Gtk.TextView()
sourcesFrame.set_label_align(0.0, 0.5) self._plugin_description_view.set_editable(False)
except Exception: self._plugin_description_view.set_cursor_visible(True)
try: self._plugin_description_view.set_accepts_tab(False)
sourcesFrame.set_label_xalign(0.0) self._plugin_description_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
except Exception: self._plugin_description_view.get_accessible().set_name("Plugin description")
pass descriptionLabel.set_mnemonic_widget(self._plugin_description_view)
sourcesFrame.set_shadow_type(Gtk.ShadowType.NONE) descriptionScrolled.add(self._plugin_description_view)
sourcesFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) pluginsPage.pack_start(descriptionScrolled, True, True, 0)
sourcesFrameBox.set_border_width(6)
sourcesFrame.add(sourcesFrameBox)
sourcesGrid = Gtk.Grid(row_spacing=6, column_spacing=6)
sourcesLabel = Gtk.Label(label="_Source URL:")
sourcesLabel.set_use_underline(True)
sourcesLabel.set_halign(Gtk.Align.START)
self._plugin_sources_entry = Gtk.Entry()
self._plugin_sources_entry.set_placeholder_text("https://example.com/repo.git")
sourcesLabel.set_mnemonic_widget(self._plugin_sources_entry)
addButton = Gtk.Button(label="Add Source")
addButton.connect("clicked", self._on_add_plugin_source)
sourcesGrid.attach(sourcesLabel, 0, 0, 1, 1)
sourcesGrid.attach(self._plugin_sources_entry, 1, 0, 1, 1)
sourcesGrid.attach(addButton, 2, 0, 1, 1)
sourcesFrameBox.pack_start(sourcesGrid, False, False, 0)
updateRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._plugin_update_button = Gtk.Button(label="Update Plugins")
self._plugin_update_button.connect("clicked", self._on_update_plugins_clicked)
self._plugin_update_progress = Gtk.ProgressBar()
self._plugin_update_progress.set_show_text(True)
self._plugin_update_progress.set_text("Idle")
updateRow.pack_start(self._plugin_update_button, False, False, 0)
updateRow.pack_start(self._plugin_update_progress, True, True, 0)
sourcesFrameBox.pack_start(updateRow, False, False, 0)
sourcesScrolled = Gtk.ScrolledWindow()
sourcesScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
sourcesScrolled.set_size_request(-1, 120)
self._plugin_sources_listbox = Gtk.ListBox()
self._plugin_sources_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
sourcesScrolled.add(self._plugin_sources_listbox)
sourcesFrameBox.pack_start(sourcesScrolled, True, True, 0)
sourcesNote = Gtk.Label(label="Use Update Plugins to install or update sources, then Apply or OK to save settings.")
sourcesNote.set_line_wrap(True)
sourcesNote.set_halign(Gtk.Align.START)
sourcesFrameBox.pack_start(sourcesNote, False, False, 0)
self._plugin_update_status = Gtk.Label(label="")
self._plugin_update_status.set_line_wrap(True)
self._plugin_update_status.set_halign(Gtk.Align.START)
sourcesFrameBox.pack_start(self._plugin_update_status, False, False, 0)
pluginsPage.pack_start(sourcesFrame, True, True, 0)
notebook = self.get_widget("notebook") notebook = self.get_widget("notebook")
notebook.append_page(pluginsPage, Gtk.Label(label="Plugins")) notebook.append_page(pluginsPage, Gtk.Label(label="Plugins"))
self._populate_plugin_list() self._populate_plugin_list()
self._populate_plugin_sources_list()
pluginsPage.show_all() pluginsPage.show_all()
def _clear_listbox(self, listbox): def _populate_plugin_list(self) -> None:
for child in listbox.get_children(): if not self._plugin_combo:
listbox.remove(child)
def _populate_plugin_list(self):
if not self._plugin_treeview:
return return
try: try:
selected_plugin = self._get_selected_plugin_name()
selected_index = 0
self._plugin_model.clear() self._plugin_model.clear()
self._plugin_iters = {} self._plugin_entries = {}
self._available_plugins = set() self._available_plugins = set()
self._plugin_canonical_map = {} self._plugin_canonical_map = {}
self._plugin_group_map = {} self._plugin_group_map = {}
@@ -607,15 +528,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
if info.builtin: if info.builtin:
canonical_builtins[canonical] = True canonical_builtins[canonical] = True
self._plugin_enabled_iter = self._plugin_model.append(
None,
[False, "Enabled plugins", False, ""]
)
self._plugin_disabled_iter = self._plugin_model.append(
None,
[False, "Disabled plugins", False, ""]
)
for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()):
plugin_name = plugin_info.get_module_name() plugin_name = plugin_info.get_module_name()
canonical_name = plugin_info.get_canonical_name() canonical_name = plugin_info.get_canonical_name()
@@ -640,242 +552,109 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
can_toggle = False can_toggle = False
display_name = plugin_info.get_name() or plugin_name display_name = plugin_info.get_name() or plugin_name
info_text = display_name
description = plugin_info.get_description() description = plugin_info.get_description()
if description:
info_text += f" - {description}"
version = plugin_info.get_version() version = plugin_info.get_version()
if version: if version:
info_text += f" (v{version})" description = f"{description}\n\nVersion: {version}" if description else f"Version: {version}"
if canonical_counts.get(canonical_name, 0) > 1: if canonical_counts.get(canonical_name, 0) > 1:
info_text += f" - Source: {plugin_info.get_source_label()}" source_label = plugin_info.get_source_label()
display_name += f" ({source_label})"
if canonical_builtins.get(canonical_name) and not plugin_info.builtin: if canonical_builtins.get(canonical_name) and not plugin_info.builtin:
info_text += " - Disabled because a builtin plugin uses this name." description += "\n\nThis copy cannot be enabled because a built-in plugin uses the same name."
if plugin_info.builtin:
description += "\n\nThis built-in plugin is always enabled."
parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter if plugin_name == selected_plugin:
tree_iter = self._plugin_model.append( selected_index = len(self._plugin_model)
parent_iter, self._plugin_model.append([display_name, plugin_name])
[is_active, info_text, can_toggle, plugin_name] self._plugin_entries[plugin_name] = {
) "active": is_active,
self._plugin_iters[plugin_name] = tree_iter "can_toggle": can_toggle,
"description": description,
}
self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name)
self._plugin_treeview.collapse_all() if len(self._plugin_model):
self._plugin_treeview.show_all() self._plugin_combo.set_active(selected_index)
else:
self._update_selected_plugin_controls()
except Exception as e: except Exception as e:
debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True)
def _populate_plugin_sources_list(self): def _get_selected_plugin_name(self) -> str | None:
if not self._plugin_sources_listbox: if not self._plugin_combo:
return None
tree_iter = self._plugin_combo.get_active_iter()
if tree_iter is None:
return None
return self._plugin_model.get_value(tree_iter, 1)
def _on_plugin_selection_changed(self, widget: Gtk.ComboBox) -> None:
self._update_selected_plugin_controls()
def _update_selected_plugin_controls(self) -> None:
plugin_name = self._get_selected_plugin_name()
plugin_entry = self._plugin_entries.get(plugin_name, {})
is_active = bool(plugin_entry.get("active", False))
can_toggle = bool(plugin_entry.get("can_toggle", False))
self._updating_plugin_controls = True
try:
self._plugin_enabled_button.set_active(is_active)
self._plugin_disabled_button.set_active(not is_active)
self._plugin_enabled_button.set_sensitive(can_toggle)
self._plugin_disabled_button.set_sensitive(can_toggle)
description = str(plugin_entry.get("description", ""))
self._plugin_description_view.get_buffer().set_text(description)
self._plugin_description_view.get_buffer().place_cursor(
self._plugin_description_view.get_buffer().get_start_iter()
)
finally:
self._updating_plugin_controls = False
def _on_plugin_enabled_toggled(self, widget: Gtk.RadioButton) -> None:
if widget.get_active() and not self._updating_plugin_controls:
self._set_plugin_active(self._get_selected_plugin_name(), True)
def _on_plugin_disabled_toggled(self, widget: Gtk.RadioButton) -> None:
if widget.get_active() and not self._updating_plugin_controls:
self._set_plugin_active(self._get_selected_plugin_name(), False)
def _set_plugin_active(self, plugin_name: str | None, is_active: bool) -> None:
plugin_entry = self._plugin_entries.get(plugin_name)
if not plugin_entry or not plugin_entry.get("can_toggle", False):
self._update_selected_plugin_controls()
return return
self._clear_listbox(self._plugin_sources_listbox) if is_active:
for source in self._plugin_sources:
row = Gtk.ListBoxRow()
row.set_activatable(False)
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
hbox.set_border_width(5)
label = Gtk.Label(label=source)
label.set_halign(Gtk.Align.START)
label.set_line_wrap(True)
remove_button = Gtk.Button(label="Remove")
remove_button.connect("clicked", self._on_remove_plugin_source, source, row)
hbox.pack_start(label, True, True, 0)
hbox.pack_start(remove_button, False, False, 0)
row.add(hbox)
self._plugin_sources_listbox.add(row)
self._plugin_sources_listbox.show_all()
def _on_add_plugin_source(self, widget):
if not self._plugin_sources_entry:
return
source = self._plugin_sources_entry.get_text().strip()
if not source:
return
if source in self._plugin_sources:
return
self._plugin_sources.append(source)
self._plugin_sources_entry.set_text("")
self._populate_plugin_sources_list()
def _on_remove_plugin_source(self, widget, source, row):
if source in self._plugin_sources:
self._plugin_sources.remove(source)
if self._plugin_sources_listbox and row:
self._plugin_sources_listbox.remove(row)
def _on_update_plugins_clicked(self, widget):
if self._plugin_update_in_progress:
return
sources = list(self._plugin_sources)
if not sources:
self._set_plugin_update_status("No sources to update.", done=True)
return
self._plugin_update_in_progress = True
if self._plugin_update_button:
self._plugin_update_button.set_sensitive(False)
self._set_plugin_update_progress(0, len(sources), "Starting updates...")
def _run_updates():
manager = cthulhu.cthulhuApp.getPluginSystemManager()
if not manager:
GLib.idle_add(self._finish_plugin_updates, "Plugin manager unavailable.")
return
def _progress_callback(index, total, message):
GLib.idle_add(self._set_plugin_update_progress, index, total, message)
manager.syncPluginSources(sources, progress_callback=_progress_callback)
try:
manager.rescanPlugins()
except Exception as e:
GLib.idle_add(self._finish_plugin_updates, f"Update finished with errors: {e}")
return
GLib.idle_add(self._finish_plugin_updates, "Update complete.")
thread = threading.Thread(target=_run_updates, daemon=True)
thread.start()
def _set_plugin_update_progress(self, index, total, message):
if not self._plugin_update_progress:
return
fraction = 0.0 if total <= 0 else min(1.0, float(index) / float(total))
self._plugin_update_progress.set_fraction(fraction)
self._plugin_update_progress.set_text(message)
self._set_plugin_update_status(message)
def _set_plugin_update_status(self, message, done=False):
if self._plugin_update_status:
self._plugin_update_status.set_text(message)
if done and self._plugin_update_progress:
self._plugin_update_progress.set_fraction(0.0)
self._plugin_update_progress.set_text("Idle")
def _finish_plugin_updates(self, message):
self._plugin_update_in_progress = False
if self._plugin_update_button:
self._plugin_update_button.set_sensitive(True)
if message:
self._set_plugin_update_status(message)
self._populate_plugin_list()
def _on_plugin_tree_toggled(self, renderer, path):
if not self._plugin_model:
return
tree_iter = self._plugin_model.get_iter(path)
if not tree_iter:
return
can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE)
if not can_toggle:
return
plugin_name = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_NAME)
if not plugin_name:
return
current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED)
new_active = not current_active
if new_active:
canonical_name = self._plugin_canonical_map.get(plugin_name) canonical_name = self._plugin_canonical_map.get(plugin_name)
for other_name in self._plugin_group_map.get(canonical_name, []): for other_name in self._plugin_group_map.get(canonical_name, []):
if other_name == plugin_name: if other_name != plugin_name:
continue self._plugin_entries[other_name]["active"] = False
self._set_plugin_row_active(other_name, False)
self._set_plugin_row_active(plugin_name, new_active) plugin_entry["active"] = is_active
self._update_selected_plugin_controls()
def _on_plugin_tree_key_press(self, widget, event):
if event.keyval != Gdk.KEY_space:
return False
selection = self._plugin_treeview.get_selection()
model, tree_iter = selection.get_selected()
if not tree_iter:
return False
path = model.get_path(tree_iter)
self._on_plugin_tree_toggled(None, path)
return True
def _on_plugin_tree_row_activated(self, treeview, path, column):
self._on_plugin_tree_toggled(None, path)
def _set_plugin_row_active(self, plugin_name, is_active):
tree_iter = self._plugin_iters.get(plugin_name)
if not tree_iter:
return
current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED)
if current_active == is_active:
return
can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE)
display_text = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_DISPLAY)
selection = self._plugin_treeview.get_selection()
model, selected_iter = selection.get_selected()
was_selected = selected_iter == tree_iter
self._plugin_model.remove(tree_iter)
parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter
new_iter = self._plugin_model.append(
parent_iter,
[is_active, display_text, can_toggle, plugin_name]
)
self._plugin_iters[plugin_name] = new_iter
if was_selected:
path = self._plugin_model.get_path(new_iter)
self._plugin_treeview.expand_to_path(path)
selection.select_path(path)
self._update_plugin_tabs() self._update_plugin_tabs()
def _get_active_plugins_from_ui(self): def _get_active_plugins_from_ui(self) -> list[str]:
existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or [])
preserved_plugins = [ preserved_plugins = [
name for name in existing_plugins name for name in existing_plugins
if name not in self._plugin_iters and name in self._available_plugins if name not in self._plugin_entries and name in self._available_plugins
] ]
selected_plugins = [] selected_plugins = []
for canonical_name, plugin_names in self._plugin_group_map.items(): for canonical_name, plugin_names in self._plugin_group_map.items():
active_in_group = [ active_in_group = [
name for name in plugin_names name for name in plugin_names
if self._plugin_iters.get(name) if self._plugin_entries.get(name, {}).get("active", False)
and self._plugin_model.get_value(self._plugin_iters[name], self.PLUGIN_COL_ENABLED)
] ]
if active_in_group: if active_in_group:
selected_plugins.append(active_in_group[-1]) selected_plugins.append(active_in_group[-1])
return preserved_plugins + selected_plugins return preserved_plugins + selected_plugins
def _apply_plugin_changes(self): def _apply_plugin_changes(self) -> None:
active_plugins = self._get_active_plugins_from_ui() active_plugins = self._get_active_plugins_from_ui()
plugin_sources = list(self._plugin_sources)
self.prefsDict["activePlugins"] = active_plugins self.prefsDict["activePlugins"] = active_plugins
self.prefsDict["pluginSources"] = plugin_sources
removed_sources = [source for source in self._plugin_sources_original if source not in plugin_sources]
manager = cthulhu.cthulhuApp.getPluginSystemManager()
if manager:
try:
manager.removePluginSources(removed_sources)
manager.rescanPlugins()
except Exception as e:
debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin sync failed: {e}", True)
self._plugin_sources_original = list(plugin_sources)
self._populate_plugin_list() self._populate_plugin_list()
self._update_plugin_tabs(active_plugins) self._update_plugin_tabs(active_plugins)
@@ -982,7 +761,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self._plugin_tabs_cached = True self._plugin_tabs_cached = True
def _get_active_plugins_for_tabs(self): def _get_active_plugins_for_tabs(self):
if self._plugin_iters: if self._plugin_entries:
return self._get_active_plugins_from_ui() return self._get_active_plugins_from_ui()
return list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) return list(self.prefsDict.get("activePlugins", settings.activePlugins) or [])
+4
View File
@@ -64,6 +64,10 @@ compositorSnapshot: Optional[Any] = None
# #
pauseAtspiChurn: bool = False pauseAtspiChurn: bool = False
# Whether a trusted screen locker has requested secure-input isolation.
#
screenLockActive: bool = False
# The desktop-context token that should be prioritized next. # The desktop-context token that should be prioritized next.
# #
prioritizedDesktopContextToken: Optional[str] = None prioritizedDesktopContextToken: Optional[str] = None
+68 -1
View File
@@ -41,7 +41,9 @@ import time
from typing import TYPE_CHECKING, Optional, Dict, List, Tuple, Any, Union from typing import TYPE_CHECKING, Optional, Dict, List, Tuple, Any, Union
from . import cthulhu from . import cthulhu
from . import braille
from . import debug from . import debug
from . import focus_manager
from . import input_event from . import input_event
from . import input_event_manager from . import input_event_manager
from . import cthulhu_state from . import cthulhu_state
@@ -132,6 +134,9 @@ class EventManager:
def _sync_focus_on_startup(self) -> bool: def _sync_focus_on_startup(self) -> bool:
"""Initialize active window and focus when startup missed focus events.""" """Initialize active window and focus when startup missed focus events."""
if cthulhu_state.screenLockActive:
return False
focus = cthulhu_state.locusOfFocus focus = cthulhu_state.locusOfFocus
if focus and not AXObject.is_dead(focus): if focus and not AXObject.is_dead(focus):
return False return False
@@ -216,8 +221,49 @@ class EventManager:
"prioritized_context_token_present": self._prioritizedContextToken is not None, "prioritized_context_token_present": self._prioritizedContextToken is not None,
"key_handling_active": self._keyHandlingActive, "key_handling_active": self._keyHandlingActive,
"input_event_manager_present": self._inputEventManager is not None, "input_event_manager_present": self._inputEventManager is not None,
"screen_lock_active": cthulhu_state.screenLockActive,
} }
def set_screen_lock_active(self, active: bool, schedule_focus_resync: bool = True) -> None:
"""Isolates screen-reader state while I38Lock owns secure input."""
if cthulhu_state.screenLockActive == active:
return
cthulhu_state.screenLockActive = active
reason = (
"I38Lock secure screen lock active"
if active
else "I38Lock secure screen lock released"
)
input_event_manager.get_manager().set_secure_input_active(active, reason)
self._discard_queued_events_for_screen_lock()
active_script = self.app.scriptManager.get_active_script()
if active and active_script is not None:
active_script.presentationInterrupt()
if active:
braille.clear()
self.app.scriptManager.set_active_script(None, reason)
manager = focus_manager.get_manager()
if manager is not None:
manager.clear_state(reason)
cthulhu_state.pendingSelfHostedFocus = None
if not active and schedule_focus_resync:
GLib.idle_add(self._sync_focus_on_startup)
def _discard_queued_events_for_screen_lock(self) -> None:
"""Drops queued accessibility events at both lock-state boundaries."""
self._gidleLock.acquire()
try:
self._eventQueue = queue.Queue(0)
self._prioritizedEvent = None
finally:
self._gidleLock.release()
def set_compositor_state_adapter(self, adapter: Any) -> None: def set_compositor_state_adapter(self, adapter: Any) -> None:
"""Stores the compositor state adapter used for startup wiring.""" """Stores the compositor state adapter used for startup wiring."""
@@ -604,6 +650,16 @@ class EventManager:
def _ignore(self, event: Atspi.Event) -> bool: def _ignore(self, event: Atspi.Event) -> bool:
"""Returns True if this event should be ignored.""" """Returns True if this event should be ignored."""
if cthulhu_state.screenLockActive:
debug.print_log(
debug.LEVEL_INFO,
"EVENT MANAGER",
"Ignoring - secure screen lock is active",
"screen-lock-active",
True,
)
return True
app = AXObject.get_application(event.source) app = AXObject.get_application(event.source)
debug.printMessage(debug.LEVEL_INFO, '') debug.printMessage(debug.LEVEL_INFO, '')
tokens = ["EVENT MANAGER:", event.type, "from", app] tokens = ["EVENT MANAGER:", event.type, "from", app]
@@ -965,6 +1021,11 @@ class EventManager:
- e: an at-spi event. - e: an at-spi event.
""" """
if cthulhu_state.screenLockActive:
msg = "EVENT MANAGER: Dropping event during secure screen lock."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
if debug.debugEventQueue: if debug.debugEventQueue:
if self._enqueueCount: if self._enqueueCount:
msg = f"EVENT MANAGER: _enqueue entered before exiting (count={self._enqueueCount})" msg = f"EVENT MANAGER: _enqueue entered before exiting (count={self._enqueueCount})"
@@ -1098,6 +1159,9 @@ class EventManager:
return True return True
def _onNoFocus(self) -> bool: def _onNoFocus(self) -> bool:
if cthulhu_state.screenLockActive:
return False
if not self._isNoFocus(): if not self._isNoFocus():
return False return False
@@ -1300,7 +1364,7 @@ class EventManager:
- event: an instance of BrailleEvent or a KeyboardEvent - event: an instance of BrailleEvent or a KeyboardEvent
""" """
if not cthulhu_state.activeScript: if cthulhu_state.screenLockActive or not cthulhu_state.activeScript:
return return
if not isinstance(event, input_event.BrailleEvent): if not isinstance(event, input_event.BrailleEvent):
@@ -1658,6 +1722,9 @@ class EventManager:
- e: an at-spi event. - e: an at-spi event.
""" """
if cthulhu_state.screenLockActive:
return
debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True) debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True)
eType = event.type eType = event.type
+18
View File
@@ -73,6 +73,7 @@ class InputEventManager:
self._mapped_keysyms: List[int] = [] self._mapped_keysyms: List[int] = []
self._grabbed_bindings: Dict[int, keybindings.KeyBinding] = {} self._grabbed_bindings: Dict[int, keybindings.KeyBinding] = {}
self._paused: bool = False self._paused: bool = False
self._secure_input_active: bool = False
self._wnck = None self._wnck = None
self._did_attempt_wnck_load: bool = False self._did_attempt_wnck_load: bool = False
self._scriptWithSuspendedGrabsForXterm = None self._scriptWithSuspendedGrabsForXterm = None
@@ -119,6 +120,7 @@ class InputEventManager:
"mapped_keysyms": len(self._mapped_keysyms), "mapped_keysyms": len(self._mapped_keysyms),
"grabbed_bindings": len(self._grabbed_bindings), "grabbed_bindings": len(self._grabbed_bindings),
"paused": self._paused, "paused": self._paused,
"secure_input_active": self._secure_input_active,
"suspended_xterm_script_present": self._scriptWithSuspendedGrabsForXterm is not None, "suspended_xterm_script_present": self._scriptWithSuspendedGrabsForXterm is not None,
"last_input_event_present": self._last_input_event is not None, "last_input_event_present": self._last_input_event is not None,
"last_non_modifier_key_event_present": self._last_non_modifier_key_event is not None, "last_non_modifier_key_event_present": self._last_non_modifier_key_event is not None,
@@ -188,6 +190,18 @@ class InputEventManager:
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
self._paused = pause self._paused = pause
def set_secure_input_active(self, active: bool, reason: str = "") -> None:
"""Suppresses ordinary keyboard processing while a trusted lock screen is active."""
if self._secure_input_active == active:
return
self._secure_input_active = active
self._last_input_event = None
self._last_non_modifier_key_event = None
msg = f"INPUT EVENT MANAGER: Secure input active: {active}. {reason}"
debug.print_message(debug.LEVEL_INFO, msg, True)
def check_grabbed_bindings(self) -> None: def check_grabbed_bindings(self) -> None:
"""Checks the grabbed key bindings.""" """Checks the grabbed key bindings."""
@@ -753,6 +767,10 @@ class InputEventManager:
) -> bool: ) -> bool:
"""Processes this Atspi keyboard event.""" """Processes this Atspi keyboard event."""
if self._secure_input_active:
msg = "INPUT EVENT MANAGER: Ignoring keyboard event during secure screen lock."
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
if self._paused: if self._paused:
msg = "INPUT EVENT MANAGER: Keyboard event processing is paused." msg = "INPUT EVENT MANAGER: Keyboard event processing is paused."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
+1
View File
@@ -87,6 +87,7 @@ cthulhu_python_sources = files([
'script.py', 'script.py',
'script_manager.py', 'script_manager.py',
'script_utilities.py', 'script_utilities.py',
'screen_lock_monitor.py',
'settings.py', 'settings.py',
'settings_manager.py', 'settings_manager.py',
'signal_manager.py', 'signal_manager.py',
+1 -1
View File
@@ -83,7 +83,7 @@ To add support for other languages, install additional Tesseract language packs:
## Usage ## Usage
1. **Enable the Plugin**: The OCR plugin is enabled by default in Cthulhu. If disabled, you can enable it through: 1. **Enable the Plugin**: The OCR plugin is enabled by default in Cthulhu. If disabled, you can enable it through:
- Cthulhu Preferences → Plugins → Check "OCR" - Cthulhu Preferences → Plugins → Select "OCR Desktop" and choose "Enabled"
- Or add `OCR` to your `activePlugins` preference - Or add `OCR` to your `activePlugins` preference
2. **Basic OCR Workflow**: 2. **Basic OCR Workflow**:
+1 -25
View File
@@ -47,28 +47,6 @@ try:
except ImportError: except ImportError:
PYTESSERACT_AVAILABLE = False PYTESSERACT_AVAILABLE = False
# pdf2image
try:
from pdf2image import convert_from_path
PDF2IMAGE_AVAILABLE = True
except ImportError:
PDF2IMAGE_AVAILABLE = False
# scipy
try:
from scipy.spatial import KDTree
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
# webcolors
try:
from webcolors import CSS3_HEX_TO_NAMES
from webcolors import hex_to_rgb
WEBCOLORS_AVAILABLE = True
except ImportError:
WEBCOLORS_AVAILABLE = False
# GTK/GDK # GTK/GDK
try: try:
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
@@ -594,9 +572,7 @@ class OCRDesktop(Plugin):
"""Get color information for OCR text (simplified version).""" """Get color information for OCR text (simplified version)."""
if not self._colorCalculation: if not self._colorCalculation:
return 'unknown' return 'unknown'
if not SCIPY_AVAILABLE or not WEBCOLORS_AVAILABLE:
return 'unknown'
# Simplified color calculation - just return "unknown" for now # Simplified color calculation - just return "unknown" for now
# Full implementation would require the color analysis from ocrdesktop # Full implementation would require the color analysis from ocrdesktop
return 'unknown' return 'unknown'
+155
View File
@@ -0,0 +1,155 @@
#!/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.
"""Tracks the lifetime of I38Lock's secure accessibility-feedback worker."""
from __future__ import annotations
import os
from typing import Any, Optional
from gi.repository import Gio, GLib
from . import debug
class ScreenLockMonitor:
"""Enters secure screen-reader mode while I38Lock owns its session-bus name."""
BUS_NAME = "org.stormux.I38Lock1"
TRUSTED_EXECUTABLE = "i38lock"
def __init__(self, app: Any) -> None:
self._app = app
self._connection: Optional[Gio.DBusConnection] = None
self._watch_id: int = 0
self._is_running: bool = False
self._is_active: bool = False
def start(self) -> None:
"""Starts watching the session bus for I38Lock."""
if self._is_running:
return
try:
self._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
except Exception as error:
msg = f"SCREEN LOCK MONITOR: Failed to connect to session bus: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
return
self._watch_id = Gio.bus_watch_name_on_connection(
self._connection,
self.BUS_NAME,
Gio.BusNameWatcherFlags.NONE,
self._on_name_appeared,
self._on_name_vanished,
)
self._is_running = True
debug.printMessage(debug.LEVEL_INFO, "SCREEN LOCK MONITOR: Started", True)
def stop(self) -> None:
"""Stops watching I38Lock and releases any active secure state."""
if self._watch_id:
Gio.bus_unwatch_name(self._watch_id)
self._watch_id = 0
if self._is_active:
self._set_active(False, schedule_focus_resync=False)
self._connection = None
self._is_running = False
debug.printMessage(debug.LEVEL_INFO, "SCREEN LOCK MONITOR: Stopped", True)
def is_running(self) -> bool:
"""Returns whether the D-Bus name watcher is running."""
return self._is_running
def is_active(self) -> bool:
"""Returns whether I38Lock currently owns its secure-state bus name."""
return self._is_active
def _set_active(self, active: bool, schedule_focus_resync: bool = True) -> None:
if self._is_active == active:
return
self._is_active = active
self._app.eventManager.set_screen_lock_active(
active,
schedule_focus_resync=schedule_focus_resync,
)
def _get_name_owner_pid(
self,
connection: Gio.DBusConnection,
name_owner: str,
) -> Optional[int]:
try:
result = connection.call_sync(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"GetConnectionUnixProcessID",
GLib.Variant("(s)", (name_owner,)),
GLib.VariantType("(u)"),
Gio.DBusCallFlags.NONE,
500,
None,
)
return result.unpack()[0]
except Exception as error:
msg = f"SCREEN LOCK MONITOR: Failed to get owner PID for {name_owner}: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
return None
def _is_trusted_owner(self, connection: Gio.DBusConnection, name_owner: str) -> bool:
pid = self._get_name_owner_pid(connection, name_owner)
if pid is None:
return False
try:
executable = os.path.basename(os.readlink(f"/proc/{pid}/exe"))
except OSError as error:
msg = f"SCREEN LOCK MONITOR: Failed to inspect owner PID {pid}: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
return False
if executable != self.TRUSTED_EXECUTABLE:
msg = f"SCREEN LOCK MONITOR: Ignoring untrusted owner executable {executable}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
return False
return True
def _on_name_appeared(
self,
connection: Gio.DBusConnection,
name: str,
name_owner: str,
_user_data: Optional[Any] = None,
) -> None:
msg = f"SCREEN LOCK MONITOR: {name} appeared as {name_owner}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
if not self._is_trusted_owner(connection, name_owner):
return
self._set_active(True)
def _on_name_vanished(
self,
_connection: Gio.DBusConnection,
name: str,
_user_data: Optional[Any] = None,
) -> None:
msg = f"SCREEN LOCK MONITOR: {name} vanished"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._set_active(False)
+36
View File
@@ -144,6 +144,37 @@ class Player:
self._stopWorkerLocked("Sound system shutdown") self._stopWorkerLocked("Sound system shutdown")
self._initialized = False self._initialized = False
def get_debug_snapshot(self) -> dict[str, Any]:
"""Returns read-only sound worker state for runtime diagnostics."""
with self._workerLock:
process = self._workerProcess
workerExitStatus: Any = ""
workerAlive = False
workerPid = 0
if process is not None:
workerPid = process.pid
workerExitStatus = process.poll()
workerAlive = workerExitStatus is None
with self._responseLock:
pendingResponseCount = len(self._pendingResponses)
return {
"gstreamer_available": _gstreamerAvailable,
"sound_system_available": isSoundSystemAvailable(),
"failure_reason": getSoundSystemFailureReason() or "",
"initialized": self._initialized,
"worker_present": process is not None,
"worker_alive": workerAlive,
"worker_pid": workerPid,
"worker_exit_status": workerExitStatus if workerExitStatus is not None else "",
"worker_sink": self._workerSink or "",
"worker_restart_required": self._workerRestartRequired,
"worker_restart_reason": self._workerRestartReason or "",
"pending_responses": pendingResponseCount,
}
def _playIcon(self, icon: Icon, interrupt: bool = True) -> None: def _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
if not icon.isValid(): if not icon.isValid():
return return
@@ -555,6 +586,7 @@ def _buildSoundHelperEnvironment() -> dict[str, str]:
environment = os.environ.copy() environment = os.environ.copy()
if pythonPathEntries: if pythonPathEntries:
environment["PYTHONPATH"] = os.pathsep.join(pythonPathEntries) environment["PYTHONPATH"] = os.pathsep.join(pythonPathEntries)
environment.setdefault("ORC_CODE", "backup")
return environment return environment
@@ -571,6 +603,10 @@ def getPlayer() -> Player:
return _player return _player
def get_debug_snapshot() -> dict[str, Any]:
return _player.get_debug_snapshot()
def play(item: Any, interrupt: bool = True) -> None: def play(item: Any, interrupt: bool = True) -> None:
_player.play(item, interrupt) _player.play(item, interrupt)
+3 -1
View File
@@ -540,7 +540,9 @@ def speak(content: Union[str, List[Any]], acss: Optional[Any] = None, interrupt:
if element.isValid(): if element.isValid():
player = sound.getPlayer() player = sound.getPlayer()
if player: if player:
player.play(element, interrupt=interrupt) # Role/state icons are short UI cues. Speech can be queued, but
# sound cues should not pile up behind stale or previous sounds.
player.play(element, interrupt=True)
elif toSpeak: elif toSpeak:
newVoice = ACSS(acss) newVoice = ACSS(acss)
newItemsToSpeak = [] newItemsToSpeak = []
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Regression tests for the plugin controls in Preferences."""
import unittest
from unittest import mock
from cthulhu import cthulhu # Import first to satisfy the application's initialization order.
from cthulhu import cthulhu_gui_prefs
class PluginPreferencesRegressionTests(unittest.TestCase):
"""Keep plugin state changes independent from their presentation widgets."""
@staticmethod
def _create_gui():
gui = cthulhu_gui_prefs.CthulhuSetupGUI.__new__(
cthulhu_gui_prefs.CthulhuSetupGUI
)
gui.prefsDict = {
"activePlugins": ["HiddenPlugin", "SourceOne"],
"pluginSources": ["https://example.com/plugins.git"],
}
gui._available_plugins = {"HiddenPlugin", "SourceOne", "SourceTwo", "Required"}
gui._plugin_entries = {
"SourceOne": {"active": True, "can_toggle": True},
"SourceTwo": {"active": False, "can_toggle": True},
"Required": {"active": True, "can_toggle": False},
}
gui._plugin_canonical_map = {
"SourceOne": "SharedPlugin",
"SourceTwo": "SharedPlugin",
"Required": "Required",
}
gui._plugin_group_map = {
"SharedPlugin": ["SourceOne", "SourceTwo"],
"Required": ["Required"],
}
gui._update_selected_plugin_controls = mock.Mock()
gui._update_plugin_tabs = mock.Mock()
return gui
def test_active_plugins_include_hidden_and_selected_plugins(self):
gui = self._create_gui()
self.assertEqual(
gui._get_active_plugins_from_ui(),
["HiddenPlugin", "SourceOne", "Required"],
)
def test_enabling_plugin_disables_other_copy_with_same_name(self):
gui = self._create_gui()
gui._set_plugin_active("SourceTwo", True)
self.assertFalse(gui._plugin_entries["SourceOne"]["active"])
self.assertTrue(gui._plugin_entries["SourceTwo"]["active"])
gui._update_selected_plugin_controls.assert_called_once_with()
gui._update_plugin_tabs.assert_called_once_with()
def test_required_plugin_cannot_be_disabled(self):
gui = self._create_gui()
gui._set_plugin_active("Required", False)
self.assertTrue(gui._plugin_entries["Required"]["active"])
gui._update_plugin_tabs.assert_not_called()
def test_apply_does_not_modify_plugin_sources(self):
gui = self._create_gui()
gui._populate_plugin_list = mock.Mock()
gui._apply_plugin_changes()
self.assertEqual(
gui.prefsDict["pluginSources"],
["https://example.com/plugins.git"],
)
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,7 @@
import sys import sys
import unittest import unittest
import importlib import importlib
import tempfile
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@@ -27,6 +28,19 @@ class PresentationInterruptSoundRegressionTests(unittest.TestCase):
echoServer.stop.assert_called_once_with() echoServer.stop.assert_called_once_with()
soundPlayer.stop.assert_called_once_with() soundPlayer.stop.assert_called_once_with()
def test_speech_icons_interrupt_previous_sound_when_speech_is_queued(self):
soundPlayer = mock.Mock()
with tempfile.TemporaryDirectory() as temporaryDirectory:
soundPath = Path(temporaryDirectory) / "button.wav"
soundPath.write_bytes(b"RIFF")
icon = speech.Icon(temporaryDirectory, "button.wav")
with mock.patch.object(speech.sound, "getPlayer", return_value=soundPlayer):
speech.speak([icon], interrupt=False)
soundPlayer.play.assert_called_once_with(icon, interrupt=True)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+211
View File
@@ -0,0 +1,211 @@
import sys
import types
import unittest
from unittest import mock
import cthulhu as cthulhu_package
from cthulhu import cthulhu_state
stubCthulhu = types.ModuleType("cthulhu.cthulhu")
stubCthulhu.cthulhuApp = mock.Mock()
stubCthulhu.cthulhuApp.getPluginSystemManager.return_value = None
previousCthulhuModule = sys.modules.get("cthulhu.cthulhu")
sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu)
from cthulhu import event_manager
from cthulhu import input_event_manager
from cthulhu.screen_lock_monitor import ScreenLockMonitor
if previousCthulhuModule is None:
sys.modules.pop("cthulhu.cthulhu", None)
if getattr(cthulhu_package, "cthulhu", None) is stubCthulhu:
delattr(cthulhu_package, "cthulhu")
else:
sys.modules["cthulhu.cthulhu"] = previousCthulhuModule
class ScreenLockMonitorTests(unittest.TestCase):
def setUp(self):
self.app = mock.Mock()
self.monitor = ScreenLockMonitor(self.app)
def test_name_appearance_enters_secure_lock_mode(self):
with mock.patch.object(self.monitor, "_is_trusted_owner", return_value=True):
self.monitor._on_name_appeared(mock.Mock(), self.monitor.BUS_NAME, ":1.42")
self.app.eventManager.set_screen_lock_active.assert_called_once_with(
True,
schedule_focus_resync=True,
)
self.assertTrue(self.monitor.is_active())
def test_untrusted_name_owner_does_not_enter_secure_lock_mode(self):
with mock.patch.object(self.monitor, "_is_trusted_owner", return_value=False):
self.monitor._on_name_appeared(mock.Mock(), self.monitor.BUS_NAME, ":1.42")
self.app.eventManager.set_screen_lock_active.assert_not_called()
self.assertFalse(self.monitor.is_active())
def test_name_disappearance_leaves_secure_lock_mode(self):
with mock.patch.object(self.monitor, "_is_trusted_owner", return_value=True):
self.monitor._on_name_appeared(mock.Mock(), self.monitor.BUS_NAME, ":1.42")
self.monitor._on_name_vanished(mock.Mock(), self.monitor.BUS_NAME)
self.assertEqual(
self.app.eventManager.set_screen_lock_active.call_args_list,
[
mock.call(True, schedule_focus_resync=True),
mock.call(False, schedule_focus_resync=True),
],
)
self.assertFalse(self.monitor.is_active())
def test_start_watches_i38lock_bus_name(self):
connection = mock.Mock()
with (
mock.patch("cthulhu.screen_lock_monitor.Gio.bus_get_sync", return_value=connection),
mock.patch(
"cthulhu.screen_lock_monitor.Gio.bus_watch_name_on_connection",
return_value=23,
) as watch_name,
):
self.monitor.start()
watch_name.assert_called_once_with(
connection,
self.monitor.BUS_NAME,
mock.ANY,
self.monitor._on_name_appeared,
self.monitor._on_name_vanished,
)
self.assertTrue(self.monitor.is_running())
def test_stop_releases_secure_lock_without_focus_resync(self):
with mock.patch.object(self.monitor, "_is_trusted_owner", return_value=True):
self.monitor._on_name_appeared(mock.Mock(), self.monitor.BUS_NAME, ":1.42")
with mock.patch("cthulhu.screen_lock_monitor.Gio.bus_unwatch_name"):
self.monitor.stop()
self.assertEqual(
self.app.eventManager.set_screen_lock_active.call_args_list,
[
mock.call(True, schedule_focus_resync=True),
mock.call(False, schedule_focus_resync=False),
],
)
self.assertFalse(self.monitor.is_active())
def test_i38lock_process_owner_is_trusted(self):
connection = mock.Mock()
result = mock.Mock()
result.unpack.return_value = (1234,)
connection.call_sync.return_value = result
with mock.patch("cthulhu.screen_lock_monitor.os.readlink", return_value="/usr/bin/i38lock"):
self.assertTrue(self.monitor._is_trusted_owner(connection, ":1.42"))
def test_non_i38lock_process_owner_is_not_trusted(self):
connection = mock.Mock()
result = mock.Mock()
result.unpack.return_value = (1234,)
connection.call_sync.return_value = result
with mock.patch("cthulhu.screen_lock_monitor.os.readlink", return_value="/usr/bin/python"):
self.assertFalse(self.monitor._is_trusted_owner(connection, ":1.42"))
class SecureScreenLockStateTests(unittest.TestCase):
def setUp(self):
self.original_screen_lock_active = cthulhu_state.screenLockActive
cthulhu_state.screenLockActive = False
def tearDown(self):
cthulhu_state.screenLockActive = self.original_screen_lock_active
def test_entering_secure_lock_clears_context_and_pauses_input(self):
app = mock.Mock()
active_script = mock.Mock()
app.scriptManager.get_active_script.return_value = active_script
manager = event_manager.EventManager(app, asyncMode=True)
keyboard_manager = mock.Mock()
focus_manager = mock.Mock()
with (
mock.patch.object(input_event_manager, "get_manager", return_value=keyboard_manager),
mock.patch("cthulhu.event_manager.focus_manager.get_manager", return_value=focus_manager),
mock.patch("cthulhu.event_manager.braille.clear") as clear_braille,
):
manager.set_screen_lock_active(True)
self.assertTrue(cthulhu_state.screenLockActive)
active_script.presentationInterrupt.assert_called_once_with()
app.scriptManager.set_active_script.assert_called_once_with(
None,
"I38Lock secure screen lock active",
)
focus_manager.clear_state.assert_called_once_with(
"I38Lock secure screen lock active"
)
keyboard_manager.set_secure_input_active.assert_called_once_with(
True,
"I38Lock secure screen lock active",
)
clear_braille.assert_called_once_with()
def test_object_events_are_dropped_without_inspecting_locked_content(self):
app = mock.Mock()
manager = event_manager.EventManager(app, asyncMode=True)
cthulhu_state.screenLockActive = True
event = mock.Mock()
with (
mock.patch.object(manager, "_addToQueue") as add_to_queue,
mock.patch("cthulhu.event_manager.AXObject.get_application") as get_application,
):
manager._enqueue(event)
add_to_queue.assert_not_called()
get_application.assert_not_called()
def test_unlock_resumes_input_and_schedules_focus_resync(self):
app = mock.Mock()
manager = event_manager.EventManager(app, asyncMode=True)
keyboard_manager = mock.Mock()
focus_manager = mock.Mock()
cthulhu_state.screenLockActive = True
with (
mock.patch.object(input_event_manager, "get_manager", return_value=keyboard_manager),
mock.patch("cthulhu.event_manager.focus_manager.get_manager", return_value=focus_manager),
mock.patch("cthulhu.event_manager.GLib.idle_add") as idle_add,
):
manager.set_screen_lock_active(False)
self.assertFalse(cthulhu_state.screenLockActive)
keyboard_manager.set_secure_input_active.assert_called_once_with(
False,
"I38Lock secure screen lock released",
)
idle_add.assert_called_once_with(manager._sync_focus_on_startup)
def test_secure_input_blocks_key_echo_before_event_creation(self):
manager = input_event_manager.InputEventManager()
manager.set_secure_input_active(True, "test lock")
with mock.patch("cthulhu.input_event_manager.input_event.KeyboardEvent") as event_class:
consumed = manager.process_keyboard_event(
mock.Mock(),
True,
25,
112,
0,
"p",
)
self.assertFalse(consumed)
event_class.assert_not_called()
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -2,6 +2,7 @@ import sys
import types import types
import unittest import unittest
import importlib import importlib
import os
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@@ -68,6 +69,18 @@ class SoundSinkTests(unittest.TestCase):
for module in reloadedModules: for module in reloadedModules:
importlib.reload(module) importlib.reload(module)
def test_sound_helper_environment_defaults_to_orc_backup(self):
with mock.patch.dict(os.environ, {}, clear=True):
environment = sound._buildSoundHelperEnvironment()
self.assertEqual("backup", environment["ORC_CODE"])
def test_sound_helper_environment_preserves_explicit_orc_code(self):
with mock.patch.dict(os.environ, {"ORC_CODE": "debug"}, clear=True):
environment = sound._buildSoundHelperEnvironment()
self.assertEqual("debug", environment["ORC_CODE"])
class PlayerRecoveryTests(unittest.TestCase): class PlayerRecoveryTests(unittest.TestCase):
def test_worker_diagnostic_marks_restart_required(self): def test_worker_diagnostic_marks_restart_required(self):