14 Commits

Author SHA1 Message Date
Storm Dragon 22758db56d Merge branch 'testing' into wine-access 2026-07-19 15:23:05 -04:00
Storm Dragon 5044455582 Had codex fix up tests. 2026-07-19 15:22:32 -04:00
Storm Dragon ef7f74ed1b Merged changes from testing. 2026-07-19 15:01:58 -04:00
Storm Dragon 3d44f6d50b Lots of work on the web side of things. More coming. 2026-07-19 15:00:25 -04:00
Storm Dragon 3e97c1ce33 Web fixes. 2026-07-18 15:10:43 -04:00
Storm Dragon afb2d804d9 Merge branch 'testing' into wine-access 2026-07-18 14:38:07 -04:00
Storm Dragon ffae1c309a More strictly adhere to nvdaControllerClient and Tolk parameters. 2026-07-18 02:53:17 -04:00
Storm Dragon 5a8a96b14f Merge branch 'testing' into wine-access stay current with testing. 2026-07-18 00:19:20 -04:00
Storm Dragon 82f3223bd2 Updated PKGBUILD. 2026-07-18 00:03:52 -04:00
Storm Dragon 2100fba3ce Harden Wine standard control support 2026-07-17 22:18:25 -04:00
Storm Dragon f6b7f2ad71 Refine Wine dialog event presentation 2026-07-17 19:51:33 -04:00
Storm Dragon 1fd9acd94b Fixed the version file so it accurately rflects the current branch. 2026-07-17 18:19:57 -04:00
Storm Dragon 1014595930 Skip Wine polling when helper is unavailable 2026-07-17 18:18:11 -04:00
Storm Dragon c9f56cb7ed Add native Wine accessibility bridge 2026-07-17 18:17:23 -04:00
43 changed files with 3733 additions and 92 deletions
+10
View File
@@ -76,6 +76,16 @@ busctl --user call org.stormux.Cthulhu1.Service /org/stormux/Cthulhu1/Service or
busctl --user call org.stormux.Cthulhu1.Service /org/stormux/Cthulhu1/Service org.stormux.Cthulhu1.Service PresentMessage s "Hello from D-Bus"
```
## Wine Accessibility Helper
On x86_64, Meson's default `-Dwine-access=auto` builds the helper when `cmake`, `winegcc`,
`wineg++`, and `widl` are available. Use `-Dwine-access=enabled` to require it or
`-Dwine-access=disabled` for an architecture-independent Cthulhu-only build.
The helper implements the NVDA Controller 1, 2, and 3 RPC interfaces and forwards standard MSAA
events to Cthulhu's D-Bus service. SSML currently returns `ERROR_NOT_SUPPORTED` because Cthulhu
cannot preserve backend-independent mark callbacks and synchronous completion semantics.
## Development Workflow
1. **Make changes** to the code
+11
View File
@@ -61,6 +61,17 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit.
- **External integration**: Other applications can speak through Cthulhu
- **Simple protocol**: `echo "text" | socat - UNIX-CLIENT:"${XDG_RUNTIME_DIR}/cthulhu.sock"`
### Wine And Proton
- **Standard controls**: MSAA events with Win32 class/message fallbacks for controls Wine leaves empty
- **Unmodified clients**: Implements the NVDA Controller RPC endpoint used by the official controller DLLs
- **Tolk compatibility**: Provides the NVDA detection window expected by the official `Tolk.dll`
- **Prefix management**: Runs one helper per active same-user Wine or Proton prefix and preserves Proton's loader
The built-in Wine Accessibility plugin is enabled by default when the helper is installed. Its
preferences page controls automatic prefix management, standard-control reading, and controller
compatibility independently. Custom-drawn game interfaces are not made accessible by this bridge.
See [Wine accessibility](docs/wine-accessibility.md) for build and protocol details.
## D-Bus Remote Controller
Cthulhu exposes a D-Bus service for external automation and integrations.
@@ -1,13 +1,14 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
pkgname=cthulhu-git
pkgbase=cthulhu-git
pkgname=(cthulhu-git cthulhu-wine-access-git)
_pkgname=cthulhu
pkgver=2026.05.25.r407.gc39f231
pkgver=2026.05.25.r423.g0576b6f
pkgrel=2
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu"
arch=(any)
arch=(x86_64 aarch64 armv7h)
license=(LGPL-2.1-or-later)
provides=("${_pkgname}=${pkgver}")
conflicts=("${_pkgname}")
@@ -65,10 +66,17 @@ optdepends=(
'python-pytesseract: Python wrapper for Tesseract OCR engine'
'tesseract: OCR engine for text recognition'
)
optdepends_x86_64=(
'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration'
)
makedepends=(
git
meson
)
makedepends_x86_64=(
cmake
wine
)
source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master")
sha512sums=('SKIP')
@@ -92,12 +100,31 @@ build() {
meson compile -C _build
}
package() {
package_cthulhu-git() {
arch=(any)
provides=("cthulhu=${pkgver}")
conflicts=(cthulhu)
cd "${_pkgname}"
meson install -C _build --destdir "$pkgdir"
rm -rf "$pkgdir/usr/libexec/cthulhu/wine"
# Remove icon cache - it will be generated by post-install hooks
rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache"
}
package_cthulhu-wine-access-git() {
pkgdesc="Wine and Proton accessibility bridge for Cthulhu"
arch=(x86_64)
depends=("cthulhu-git=${pkgver}" glib2 wine)
provides=(cthulhu-wine-access)
conflicts=(cthulhu-wine-access)
cd "${_pkgname}"
install -Dm755 _build/cthulhu-wine-access.exe \
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"
install -Dm755 _build/cthulhu-wine-access.exe.so \
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so"
}
# vim:set sw=2 sts=-1 et:
+32 -5
View File
@@ -1,12 +1,13 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
pkgname=cthulhu
pkgver=2026.05.25
pkgrel=2
pkgbase=cthulhu
pkgname=(cthulhu cthulhu-wine-access)
pkgver=2026.07.18
pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu"
arch=(any)
arch=(x86_64 aarch64 armv7h)
license=(LGPL-2.1-or-later)
depends=(
# Core AT-SPI accessibility
@@ -62,10 +63,17 @@ optdepends=(
'python-pytesseract: Python wrapper for Tesseract OCR engine'
'tesseract: OCR engine for text recognition'
)
optdepends_x86_64=(
'cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration'
)
makedepends=(
git
meson
)
makedepends_x86_64=(
cmake
wine
)
source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}")
sha512sums=('SKIP')
@@ -75,12 +83,31 @@ build() {
meson compile -C _build
}
package() {
package_cthulhu() {
arch=(any)
provides=("cthulhu=${pkgver}")
conflicts=(cthulhu)
cd cthulhu
meson install -C _build --destdir "$pkgdir"
rm -rf "$pkgdir/usr/libexec/cthulhu/wine"
# Remove icon cache - it will be generated by post-install hooks
rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache"
}
package_cthulhu-wine-access() {
pkgdesc="Wine and Proton accessibility bridge for Cthulhu"
arch=(x86_64)
depends=("cthulhu=${pkgver}" glib2 wine)
provides=(cthulhu-wine-access)
conflicts=(cthulhu-wine-access)
cd cthulhu
install -Dm755 _build/cthulhu-wine-access.exe \
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe"
install -Dm755 _build/cthulhu-wine-access.exe.so \
"$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so"
}
# vim:set sw=2 sts=-1 et:
@@ -1,5 +1,8 @@
# Tolk NVDA Presence Compatibility Design
> Superseded by `docs/wine-accessibility.md`, which uses the original NVDA Controller clients and
> a native RPC server instead of replacement controller DLLs.
## Goal
Allow applications running under Wine or Proton to use the official upstream `Tolk.dll` unchanged while routing Tolk speech through the existing Linux NVDA-to-Cthulhu path.
+53
View File
@@ -0,0 +1,53 @@
# Wine Accessibility
Cthulhu's Wine integration has three components:
1. `WineAccessibility`, a built-in plugin which discovers same-user Wine and Proton prefixes.
2. `cthulhu-wine-access.exe`, a Wine-hosted helper built with Winegcc.
3. The `org.stormux.Cthulhu1.Service` D-Bus interface used by the helper and Prism.
The manager starts one helper per prefix, uses `WINEPREFIX` or
`STEAM_COMPAT_DATA_PATH/pfx`, and passes the prefix's own loader through `WINELOADER`. Discovery
requires an actual Wine loader process, so exported prefix variables and idle `wineserver`
processes do not keep helpers alive. A helper is stopped after its prefix has been idle for ten seconds. Repeated early exits use exponential
restart backoff. If an actual NVDA window already exists in the prefix, the helper yields the
controller endpoint to it while continuing to provide standard-dialog reading.
## Controller Compatibility
The helper registers the current `NvdaCtlr.<session>.<desktop>` local RPC endpoint, the legacy
`NvdaCtlr` endpoint used by older official controller clients, and the Controller 1, 2, and 3
interface UUIDs. It also creates the `wxWindowClassNR` / `NVDA` window used
by official Tolk releases for detection. The compatibility window is explicitly hidden,
nonactivating, and excluded from ordinary window switching. Therefore applications can keep the original
`nvdaControllerClient32.dll`, `nvdaControllerClient64.dll`, and `Tolk.dll`; no DLL override is
required.
Plain speech queues like NVDA Controller output; Tolk's interrupt flag uses the separate cancel call
before speaking. Braille, cancel, and process ID calls are also implemented. Speaking-state queries return
`ERROR_CALL_NOT_IMPLEMENTED`, because Cthulhu's current speech backend does not expose an accurate
answer. SSML is validated by Cthulhu and currently fails with `ERROR_NOT_SUPPORTED`. This is
intentional: accepting SSML while dropping mark callbacks or reporting completion too early would
violate the Controller 2 contract.
The current Meson target builds the 64-bit helper. A 32-bit helper requires a 32-bit Winegcc and
32-bit GLib/GIO development environment and should be shipped by multilib packaging.
## Standard Dialog Reading
The first reader stage listens for MSAA focus, name, value, state, and selection events. When Wine's
MSAA proxy has no useful data for a standard control, the helper falls back to the control's Win32
class and message protocol. Combo boxes, list boxes, edits, buttons, sliders, and static controls can
therefore expose useful names, roles, values, and states without replacing the MSAA path for controls
which already read correctly.
This class-based fallback is adapted from the LGPL `wine-a11y` reader published by Mudb0y.
The reader does not expose custom-drawn, DirectX, or game-specific interfaces. UI Automation coverage
and an AT-SPI object broker remain separate later stages.
## Prism
Prism has a distinct `Cthulhu` backend ID. Native applications call Cthulhu's D-Bus service
directly. Wine builds use Prism's Winelib D-Bus bridge. This backend supports speech, braille,
combined output, and cancellation without claiming to be Orca.
+49 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu',
version: '2026.05.25',
version: '2026.07.18',
meson_version: '>= 1.0.0',
)
@@ -61,6 +61,54 @@ optional_modules = {
}
summary = {}
wine_access_mode = get_option('wine-access')
wine_access_tools = {
'cmake': find_program('cmake', required: false),
'winegcc': find_program('winegcc', required: false),
'wineg++': find_program('wineg++', required: false),
'widl': find_program('widl', required: false),
}
wine_access_enabled = wine_access_mode != 'disabled' and host_machine.cpu_family() == 'x86_64'
foreach tool_name, tool : wine_access_tools
if not tool.found()
wine_access_enabled = false
if wine_access_mode == 'enabled'
error(f'-Dwine-access=enabled requires @tool_name@')
endif
endif
endforeach
if wine_access_mode == 'enabled' and host_machine.cpu_family() != 'x86_64'
error('-Dwine-access=enabled currently requires an x86_64 build host')
endif
if wine_access_enabled
wine_access_outputs = custom_target(
'wine-access-helper',
output: ['cthulhu-wine-access.exe', 'cthulhu-wine-access.exe.so'],
command: [
find_program('bash'),
files('scripts/build-wine-access.sh'),
meson.current_source_dir() / 'wine-access',
meson.current_build_dir() / 'wine-access-build',
'@OUTPUT0@',
'@OUTPUT1@',
],
depend_files: files(
'scripts/build-wine-access.sh',
'wine-access/CMakeLists.txt',
'wine-access/main.cpp',
'wine-access/nvdaController.idl',
'wine-access/winelib-toolchain.cmake',
),
build_by_default: true,
install: true,
install_dir: get_option('libexecdir') / 'cthulhu' / 'wine',
)
summary += {'Wine accessibility': 'yes (Winelib x86_64 helper)'}
else
summary += {'Wine accessibility': 'no'}
endif
foreach module, description : optional_modules
result = python.find_installation('python3', modules:[module], required: false)
if result.found()
+8 -1
View File
@@ -1,2 +1,9 @@
option('plugin-system', type: 'boolean', value: true, description: 'Enable plugin system support')
option('d-bus-service', type: 'boolean', value: true, description: 'Enable D-Bus remote controller service')
option('d-bus-service', type: 'boolean', value: true, description: 'Enable D-Bus remote controller service')
option(
'wine-access',
type: 'combo',
choices: ['auto', 'enabled', 'disabled'],
value: 'auto',
description: 'Build the Wine accessibility and NVDA Controller helper'
)
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
sourceDir=$1
buildDir=$2
launcherOutput=$3
binaryOutput=$4
cmake \
-S "$sourceDir" \
-B "$buildDir" \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_TOOLCHAIN_FILE="$sourceDir/winelib-toolchain.cmake"
cmake --build "$buildDir" --parallel
cmake -E copy "$buildDir/cthulhu-wine-access.exe" "$launcherOutput"
cmake -E copy "$buildDir/cthulhu-wine-access.exe.so" "$binaryOutput"
+2 -2
View File
@@ -254,6 +254,8 @@ class AXUtilitiesEvent:
reason = TextEventReason.SELECT_ALL
elif mgr.last_event_was_primary_click_or_release():
reason = TextEventReason.MOUSE_PRIMARY_BUTTON
elif mgr.last_event_was_tab_navigation():
reason = TextEventReason.FOCUS_CHANGE
elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj):
if mgr.last_event_was_backspace():
reason = TextEventReason.BACKSPACE
@@ -273,8 +275,6 @@ class AXUtilitiesEvent:
reason = TextEventReason.UNSPECIFIED_COMMAND
elif mgr.last_event_was_printable_key():
reason = TextEventReason.TYPING
elif mgr.last_event_was_tab_navigation():
reason = TextEventReason.FOCUS_CHANGE
elif AXObject.find_ancestor(obj, AXUtilitiesRole.children_are_presentational):
reason = TextEventReason.UI_UPDATE
return reason
+7
View File
@@ -304,6 +304,13 @@ class Backend:
def _migrateSettings(self, settingsDict):
"""Migrate old setting names to new ones."""
migrationKey = 'wineAccessibilityPluginMigrated'
activePlugins = settingsDict.get('activePlugins')
if isinstance(activePlugins, list) and migrationKey not in settingsDict:
if 'WineAccessibility' not in activePlugins:
activePlugins.append('WineAccessibility')
settingsDict[migrationKey] = True
# Migration: enableSpeechIndentation -> enableIndentation
if 'enableSpeechIndentation' in settingsDict and 'enableIndentation' not in settingsDict:
settingsDict['enableIndentation'] = settingsDict['enableSpeechIndentation']
+2 -2
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.07.18"
codeName = "master"
version = "2026.07.19"
codeName = "wine-access"
+156
View File
@@ -25,6 +25,7 @@ import contextlib
import enum
import inspect
import os
import time
import types
import typing
import xml.etree.ElementTree as ET
@@ -641,6 +642,8 @@ class _InterfaceBuilder:
class CthulhuDBusServiceInterface(Publishable):
"""Internal D-Bus service object that handles D-Bus specifics."""
_WINE_FOCUS_DEBOUNCE_MS = 50
def for_publication(self):
"""Returns the D-Bus interface XML for publication."""
@@ -678,6 +681,159 @@ class CthulhuDBusServiceInterface(Publishable):
script.presentMessage(message)
return True
def SpeakText(self, text: str, interrupt: bool) -> bool: # pylint: disable=invalid-name
"""Speaks plain text without also presenting it in braille."""
if not text:
return False
from . import speech # pylint: disable=import-outside-toplevel
speech.speak(text, interrupt=interrupt)
return True
def BrailleMessage(self, text: str) -> bool: # pylint: disable=invalid-name
"""Displays a message on the active braille display."""
if not text:
return False
from . import braille # pylint: disable=import-outside-toplevel
braille.displayMessage(text, flashTime=-1)
return True
def CancelSpeech(self) -> bool: # pylint: disable=invalid-name
"""Stops current speech and sound output."""
from . import speech # pylint: disable=import-outside-toplevel
speech.stop()
return True
def SpeakSsml(self, requestId: str, ssml: str) -> int: # pylint: disable=invalid-name
"""Rejects SSML when its timing and mark semantics cannot be preserved."""
del requestId
try:
root = ET.fromstring(ssml)
except ET.ParseError:
debug.print_message(debug.LEVEL_WARNING, "DBUS SERVICE: Invalid SSML rejected", True)
return 87 # ERROR_INVALID_PARAMETER
if root.tag.rsplit("}", 1)[-1].lower() != "speak":
debug.print_message(debug.LEVEL_WARNING, "DBUS SERVICE: Non-speak SSML rejected", True)
return 87
debug.print_message(
debug.LEVEL_INFO,
"DBUS SERVICE: SSML rejected because mark and completion semantics are unavailable",
True,
)
return 50 # ERROR_NOT_SUPPORTED
def PresentAccessibleEvent( # pylint: disable=invalid-name,too-many-arguments
self,
sourceId: str,
eventType: str,
name: str,
role: str,
value: str,
states: str,
description: str,
position: int,
count: int,
windowTitle: str,
) -> bool:
"""Presents a normalized accessibility event received from Wine."""
del states
eventType = eventType.casefold()
debug.print_message(
debug.LEVEL_INFO,
(
"WINE ACCESS EVENT: "
f"type={eventType} source={sourceId} name={name!r} "
f"role={role!r} value={value!r} window={windowTitle!r}"
),
True,
)
if eventType == "focus":
self._wineAccessibleFocusSource = sourceId
else:
if eventType not in {"value", "selection"}:
return False
focusedSource = getattr(self, "_wineAccessibleFocusSource", None)
focusedWindow = getattr(self, "_wineAccessibleWindowTitle", None)
pendingFocus = getattr(self, "_wineAccessiblePendingFocus", None)
if focusedWindow is None and pendingFocus is not None:
focusedWindow = pendingFocus[2]
if sourceId != focusedSource:
if eventType != "selection":
return False
if not windowTitle or windowTitle != focusedWindow:
return False
if role.casefold() == "client":
role = ""
parts = [part for part in (name, role, value, description) if part]
if position > 0 and count > 0:
parts.append(f"{position} of {count}")
if not parts and not (eventType == "focus" and windowTitle):
return False
message = ", ".join(parts)
now = time.monotonic()
signature = (sourceId, message or windowTitle)
lastSignature = getattr(self, "_wineAccessibleLastSignature", None)
lastTime = getattr(self, "_wineAccessibleLastTime", 0.0)
if signature == lastSignature and now - lastTime < 0.1:
if eventType == "focus":
self._wineAccessiblePendingFocus = None
return False
if eventType == "focus":
self._wineAccessiblePendingFocus = (signature, parts, windowTitle)
if not getattr(self, "_wineAccessibleFocusTimeoutId", 0):
self._wineAccessibleFocusTimeoutId = GLib.timeout_add(
self._WINE_FOCUS_DEBOUNCE_MS,
self._present_pending_accessible_focus,
)
return True
pending = getattr(self, "_wineAccessiblePendingFocus", None)
if pending is not None and (
pending[0][0] == sourceId or (windowTitle and pending[2] == windowTitle)
):
self._wineAccessiblePendingFocus = (signature, parts, pending[2])
return True
self._wineAccessibleLastSignature = signature
self._wineAccessibleLastTime = now
return self.PresentMessage(message)
def _present_pending_accessible_focus(self) -> bool:
"""Presents the final focus event after Wine's transient focus burst."""
pending = getattr(self, "_wineAccessiblePendingFocus", None)
self._wineAccessiblePendingFocus = None
self._wineAccessibleFocusTimeoutId = 0
if pending is None:
return False
signature, parts, windowTitle = pending
self._wineAccessibleLastSignature = signature
self._wineAccessibleLastTime = time.monotonic()
parts = list(parts)
if windowTitle:
previousTitle = getattr(self, "_wineAccessibleWindowTitle", None)
self._wineAccessibleWindowTitle = windowTitle
if windowTitle != previousTitle and windowTitle not in parts:
parts.append(windowTitle)
if parts:
self.PresentMessage(", ".join(parts))
return False
def GetVersion(self) -> str: # pylint: disable=invalid-name
"""Returns Cthulhu's version and revision if available."""
+152 -4
View File
@@ -46,6 +46,7 @@ import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
from gi.repository import GLib
import pyatspi
from . import debug
from . import focus_manager
@@ -61,6 +62,44 @@ from .ax_utilities_application import AXUtilitiesApplication
if TYPE_CHECKING:
from . import keybindings
_X11_SHIFT_MASK = 1 << int(Atspi.ModifierType.SHIFT)
_X11_SHIFT_LOCK_MASK = 1 << int(Atspi.ModifierType.SHIFTLOCK)
_X11_NUM_LOCK_MASK = 1 << int(Atspi.ModifierType.NUMLOCK)
_X11_CONSUMABLE_MODIFIER_MASKS = (
0,
_X11_SHIFT_MASK,
_X11_SHIFT_LOCK_MASK,
_X11_SHIFT_MASK | _X11_SHIFT_LOCK_MASK,
_X11_NUM_LOCK_MASK,
_X11_SHIFT_MASK | _X11_NUM_LOCK_MASK,
_X11_SHIFT_LOCK_MASK | _X11_NUM_LOCK_MASK,
_X11_SHIFT_MASK | _X11_SHIFT_LOCK_MASK | _X11_NUM_LOCK_MASK,
)
_X11_CONSUMABLE_KEYSYMS = (
"a", "b", "c", "d", "e", "f", "g", "h", "i", "k", "l", "m",
"o", "p", "q", "r", "s", "t", "u", "v", "x", "y",
"1", "2", "3", "4", "5", "6", "comma",
)
def _get_x11_consumable_key_set() -> List[Atspi.KeyDefinition]:
"""Returns AT-SPI definitions for unmodified structural-navigation keys."""
# Importing here avoids a module-level cycle: keybindings obtains this
# manager lazily when bindings install their AT-SPI grabs.
from . import keybindings # pylint: disable=import-outside-toplevel
keycodes = sorted({
keycode
for keysym in _X11_CONSUMABLE_KEYSYMS
if (keycode := keybindings.get_keycodes(keysym)[1])
})
keySet = []
for keycode in keycodes:
definition = Atspi.KeyDefinition()
definition.keycode = keycode
keySet.append(definition)
return keySet
class InputEventManager:
"""Provides utilities for managing input events."""
@@ -68,6 +107,11 @@ class InputEventManager:
self._last_input_event: Optional[input_event.InputEvent] = None
self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None
self._device: Optional[Atspi.Device] = None
self._key_pressed_id: int = 0
self._key_released_id: int = 0
self._selective_legacy_listener_active: bool = False
self._selective_legacy_key_set: List[Atspi.KeyDefinition] = []
self._selective_legacy_keycodes: set[int] = set()
self._pointer_moved_id: int = 0
self._mapped_keycodes: List[int] = []
self._mapped_keysyms: List[int] = []
@@ -101,13 +145,56 @@ class InputEventManager:
msg = "INPUT EVENT MANAGER: Starting key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True)
self.activate_device().add_key_watcher(self.process_keyboard_event)
device = self.activate_device()
atspiVersion = Atspi.get_version()
if atspiVersion[0] > 2 or atspiVersion[1] >= 60:
msg = "INPUT EVENT MANAGER: Using AT-SPI key signals."
debug.print_message(debug.LEVEL_INFO, msg, True)
self._key_pressed_id = device.connect("key-pressed", self._on_key_pressed)
self._key_released_id = device.connect("key-released", self._on_key_released)
if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
self._selective_legacy_key_set = _get_x11_consumable_key_set()
if self._selective_legacy_key_set:
self._selective_legacy_keycodes = {
definition.keycode for definition in self._selective_legacy_key_set
}
msg = "INPUT EVENT MANAGER: Adding selective consumable X11 key listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
pyatspi.Registry.registerKeystrokeListener(
self._process_selective_legacy_keyboard_event,
key_set=self._selective_legacy_key_set,
mask=list(_X11_CONSUMABLE_MODIFIER_MASKS),
kind=(Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT),
synchronous=True,
preemptive=True,
)
self._selective_legacy_listener_active = True
else:
msg = "INPUT EVENT MANAGER: Using legacy AT-SPI key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True)
device.add_key_watcher(self.process_keyboard_event)
def stop_key_watcher(self) -> None:
"""Starts the watcher for keyboard input events."""
"""Stops the watcher for keyboard input events."""
msg = "INPUT EVENT MANAGER: Stopping key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True)
if self._selective_legacy_listener_active:
pyatspi.Registry.deregisterKeystrokeListener(
self._process_selective_legacy_keyboard_event,
key_set=self._selective_legacy_key_set,
mask=list(_X11_CONSUMABLE_MODIFIER_MASKS),
kind=(Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT),
)
self._selective_legacy_listener_active = False
self._selective_legacy_key_set = []
self._selective_legacy_keycodes.clear()
if self._device is not None:
for handlerId in (self._key_pressed_id, self._key_released_id):
if handlerId:
self._device.disconnect(handlerId)
self._key_pressed_id = 0
self._key_released_id = 0
self._device = None
def get_debug_snapshot(self) -> Dict[str, object]:
@@ -229,6 +316,11 @@ class InputEventManager:
grab_ids = []
for kd in binding.key_definitions():
definitionKeycode = kd.keycode or binding.keycode
if self._signal_event_uses_selective_legacy_listener(
definitionKeycode, kd.modifiers
):
continue
grab_id = self._device.add_key_grab(kd, None)
if grab_id == 0:
continue
@@ -756,6 +848,62 @@ class InputEventManager:
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
def _process_selective_legacy_keyboard_event(self, event: Atspi.DeviceEvent) -> bool:
"""Returns an X11 consume decision for unmodified browse-navigation keys."""
pressed = event.type == Atspi.EventType.KEY_PRESSED_EVENT
return self.process_keyboard_event(
self._device,
pressed,
event.hw_code,
event.id,
event.modifiers,
event.event_string or "",
)
def _signal_event_uses_selective_legacy_listener(self, keycode: int, modifiers: int) -> bool:
"""Returns True if the X11 compatibility listener owns this event."""
return (
self._selective_legacy_listener_active
and keycode in self._selective_legacy_keycodes
and modifiers in _X11_CONSUMABLE_MODIFIER_MASKS
)
def _on_key_pressed(
self,
device: Atspi.Device,
keycode: int,
keysym: int,
modifiers: int,
text: str,
) -> None:
"""Processes a key-pressed signal from AT-SPI 2.60 and later."""
if self._signal_event_uses_selective_legacy_listener(keycode, modifiers):
msg = "INPUT EVENT MANAGER: Deferring key-pressed signal to X11 consumable listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self.process_keyboard_event(device, True, keycode, keysym, modifiers, text)
def _on_key_released(
self,
device: Atspi.Device,
keycode: int,
keysym: int,
modifiers: int,
text: str,
) -> None:
"""Processes a key-released signal from AT-SPI 2.60 and later."""
if self._signal_event_uses_selective_legacy_listener(keycode, modifiers):
msg = "INPUT EVENT MANAGER: Deferring key-released signal to X11 consumable listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self.process_keyboard_event(device, False, keycode, keysym, modifiers, text)
def process_keyboard_event(
self,
_device: Atspi.Device,
@@ -849,7 +997,7 @@ class InputEventManager:
event._finalize_initialization()
event.set_click_count(self._determine_keyboard_event_click_count(event))
event.process()
consumed = event.process()
if event.is_modifier_key():
if self.is_release_for(event, self._last_input_event):
@@ -859,7 +1007,7 @@ class InputEventManager:
else:
self._last_non_modifier_key_event = event
self._last_input_event = event
return True
return consumed
# pylint: enable=too-many-arguments
# pylint: enable=too-many-positional-arguments
+1
View File
@@ -116,6 +116,7 @@ cthulhu_python_sources = files([
'typing_echo_presenter.py',
'wnck_support.py',
'where_am_i_presenter.py',
'wine_access_manager.py',
])
# Note: Main executable (cthulhu) is installed from src/cthulhu.py
@@ -0,0 +1,5 @@
"""Wine accessibility integration plugin."""
from .plugin import WineAccessibility
__all__ = ["WineAccessibility"]
@@ -0,0 +1,9 @@
python3.install_sources(
files('__init__.py', 'plugin.py'),
subdir: 'cthulhu/plugins/WineAccessibility'
)
install_data(
'plugin.info',
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'WineAccessibility'
)
@@ -0,0 +1,8 @@
name = Wine Accessibility
version = 1.0.0
description = Provides screen reader access to standard Wine and Proton applications
authors = Stormux
website = https://git.stormux.org/storm/cthulhu
copyright = Copyright 2026
builtin = false
hidden = false
@@ -0,0 +1,104 @@
"""Automatically manages Cthulhu's Wine accessibility helper."""
from gi.repository import GLib, Gtk
from cthulhu import settings
from cthulhu import settings_manager
from cthulhu.plugin import Plugin, cthulhu_hookimpl
from cthulhu.wine_access_manager import WineAccessManager
class WineAccessibility(Plugin):
"""Starts one helper for each active same-user Wine or Proton prefix."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.settingsManager = settings_manager.getManager()
self.manager = WineAccessManager()
self.pollSourceId = 0
self.prefsGrid = None
self.prefsWidgets = {}
@cthulhu_hookimpl
def activate(self, plugin=None):
if plugin is not None and plugin is not self:
return
self._refresh_manager_settings()
if self._setting("wineAccessibilityEnabled") and self._setting(
"wineAccessibilityAutoManage"
) and self.manager.helperPath:
self.manager.poll()
self.pollSourceId = GLib.timeout_add(self.manager.POLL_INTERVAL_MS, self.manager.poll)
@cthulhu_hookimpl
def deactivate(self, plugin=None):
if plugin is not None and plugin is not self:
return
if self.pollSourceId:
GLib.source_remove(self.pollSourceId)
self.pollSourceId = 0
self.manager.shutdown()
def _setting(self, name):
value = self.settingsManager.getSetting(name)
return getattr(settings, name) if value is None else value
def _refresh_manager_settings(self):
self.manager.dialogReaderEnabled = bool(self._setting("wineAccessibilityDialogReader"))
self.manager.controllerEnabled = bool(self._setting("wineAccessibilityController"))
def getPreferencesGUI(self):
if self.prefsGrid is None:
self.prefsGrid = Gtk.Grid(
row_spacing=6,
column_spacing=12,
margin_left=12,
margin_right=12,
margin_top=12,
margin_bottom=12,
)
labels = (
("enabled", "Enable Wine accessibility"),
("auto", "Automatically manage Wine and Proton prefixes"),
("dialogs", "Read standard Wine controls"),
("controller", "Enable NVDA Controller and Tolk compatibility"),
)
for row, (name, label) in enumerate(labels):
widget = Gtk.CheckButton(label=label)
if name == "enabled":
widget.connect("toggled", self._update_preferences_sensitivity)
self.prefsGrid.attach(widget, 0, row, 1, 1)
self.prefsWidgets[name] = widget
values = {
"enabled": "wineAccessibilityEnabled",
"auto": "wineAccessibilityAutoManage",
"dialogs": "wineAccessibilityDialogReader",
"controller": "wineAccessibilityController",
}
for name, settingName in values.items():
self.prefsWidgets[name].set_active(bool(self._setting(settingName)))
self._update_preferences_sensitivity()
return self.prefsGrid, "Wine Accessibility"
def getPreferencesFromGUI(self):
if not self.prefsWidgets:
return {}
return {
"wineAccessibilityEnabled": self.prefsWidgets["enabled"].get_active(),
"wineAccessibilityAutoManage": self.prefsWidgets["auto"].get_active(),
"wineAccessibilityDialogReader": self.prefsWidgets["dialogs"].get_active(),
"wineAccessibilityController": self.prefsWidgets["controller"].get_active(),
}
def _update_preferences_sensitivity(self, _widget=None):
if not self.prefsWidgets:
return
enabled = self.prefsWidgets["enabled"].get_active()
for name in ("auto", "dialogs", "controller"):
self.prefsWidgets[name].set_sensitive(enabled)
def refresh_settings(self):
"""Applies settings saved from the plugin preferences page."""
self.deactivate()
self.activate()
+1
View File
@@ -14,3 +14,4 @@ subdir('hello_world')
subdir('self_voice')
subdir('SSIPProxy')
subdir('WindowTitleReader')
subdir('WineAccessibility')
+5 -7
View File
@@ -60,6 +60,7 @@ from cthulhu.scripts import default
from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText
from cthulhu.ax_utilities import AXUtilities
from cthulhu.ax_utilities_event import AXUtilitiesEvent, TextEventReason
from .bookmarks import Bookmarks
from .braille_generator import BrailleGenerator
@@ -2104,6 +2105,7 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
reason = AXUtilitiesEvent.get_text_event_reason(event)
obj, offset = self.utilities.getCaretContext(document, False, False)
tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
@@ -2156,14 +2158,10 @@ class Script(default.Script):
self.updateBraille(event.source)
return True
if self.utilities.lastInputEventWasTab():
msg = "WEB: Last input event was Tab."
if reason == TextEventReason.FOCUS_CHANGE:
msg = "WEB: Event ignored: Caret moved due to focus change."
debug.printMessage(debug.LEVEL_INFO, msg, True)
if self.utilities.isDocument(event.source):
msg = "WEB: Event ignored: Caret moved in document due to Tab."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
return True
if self.utilities.inFindContainer():
msg = "WEB: Event handled: Presenting find results"
+10 -1
View File
@@ -65,6 +65,10 @@ userCustomizableSettings = [
"useCustomEchoForSentence",
"gameMode",
"nvda2cthulhuTranslateEnabled",
"wineAccessibilityEnabled",
"wineAccessibilityAutoManage",
"wineAccessibilityDialogReader",
"wineAccessibilityController",
"enableAlphabeticKeys",
"enableNumericKeys",
"enablePunctuationKeys",
@@ -304,6 +308,10 @@ messagesAreDetailed = True
enablePauseBreaks = True
gameMode = False
nvda2cthulhuTranslateEnabled = False
wineAccessibilityEnabled = True
wineAccessibilityAutoManage = True
wineAccessibilityDialogReader = True
wineAccessibilityController = True
speakDescription = True
speakContextBlockquote = True
speakContextPanel = True
@@ -506,7 +514,8 @@ activePlugins = [
'OCR',
'SpeechHistory',
'SSIPProxy',
'WindowTitleReader'
'WindowTitleReader',
'WineAccessibility'
]
pluginSources = []
+223
View File
@@ -0,0 +1,223 @@
"""Manages one Cthulhu Wine accessibility helper per active prefix."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import time
from typing import Optional
from . import debug
from . import cthulhu_platform
class PrefixProcess:
"""Tracks a helper process and restart state for one Wine prefix."""
def __init__(self) -> None:
self.process: Optional[subprocess.Popen] = None
self.lastSeen = 0.0
self.startedAt = 0.0
self.failures = 0
self.nextStart = 0.0
class WineAccessManager:
"""Discovers Wine processes and supervises prefix-scoped helpers."""
POLL_INTERVAL_MS = 2000
IDLE_GRACE_SECONDS = 10.0
MAX_BACKOFF_SECONDS = 60.0
MANAGED_ENVIRONMENT_KEY = "CTHULHU_WINE_ACCESS_MANAGED"
def __init__(self, helperPath: Optional[str] = None) -> None:
self.helperPath = helperPath or self._find_helper()
self.prefixes: dict[str, PrefixProcess] = {}
self.dialogReaderEnabled = True
self.controllerEnabled = True
@staticmethod
def _find_helper() -> Optional[str]:
configured = os.environ.get("CTHULHU_WINE_ACCESS_HELPER")
if configured and os.access(configured, os.X_OK):
return configured
installed = os.path.join(
cthulhu_platform.prefix,
"libexec",
"cthulhu",
"wine",
"cthulhu-wine-access.exe",
)
if os.access(installed, os.X_OK):
return installed
return shutil.which("cthulhu-wine-access.exe")
@staticmethod
def _read_environ(pid: int) -> dict[str, str]:
try:
raw = Path(f"/proc/{pid}/environ").read_bytes()
except (OSError, PermissionError):
return {}
result = {}
for entry in raw.split(b"\0"):
if b"=" not in entry:
continue
key, value = entry.split(b"=", 1)
result[key.decode(errors="replace")] = value.decode(errors="replace")
return result
@staticmethod
def _prefix_from_environ(environment: dict[str, str]) -> Optional[str]:
prefix = environment.get("WINEPREFIX")
if not prefix:
compatPath = environment.get("STEAM_COMPAT_DATA_PATH")
if compatPath:
prefix = os.path.join(compatPath, "pfx")
if not prefix:
return None
return os.path.realpath(os.path.expanduser(prefix))
@staticmethod
def _loader_for_process(pid: int, environment: dict[str, str]) -> Optional[str]:
configured = environment.get("WINELOADER")
if configured and os.access(configured, os.X_OK):
return configured
try:
executable = os.readlink(f"/proc/{pid}/exe")
except OSError:
return None
name = os.path.basename(executable).lower()
if name in {"wine", "wine64"}:
return executable
if name in {"wine-preloader", "wine64-preloader"}:
loaderName = name.removesuffix("-preloader")
loader = os.path.join(os.path.dirname(executable), loaderName)
if os.access(loader, os.X_OK):
return loader
return None
def discover_prefixes(self) -> dict[str, tuple[dict[str, str], Optional[str]]]:
"""Returns active prefixes with the environment and loader that created them."""
discovered = {}
ownUid = os.getuid()
helperPids = {
state.process.pid
for state in self.prefixes.values()
if state.process is not None and state.process.poll() is None
}
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
try:
if entry.stat().st_uid != ownUid:
continue
except OSError:
continue
pid = int(entry.name)
if pid in helperPids:
continue
environment = self._read_environ(pid)
if environment.get(self.MANAGED_ENVIRONMENT_KEY) == "1":
continue
prefix = self._prefix_from_environ(environment)
if prefix is None:
continue
loader = self._loader_for_process(pid, environment)
if loader is None:
continue
previous = discovered.get(prefix)
if previous is None:
discovered[prefix] = (environment, loader)
return discovered
def poll(self) -> bool:
"""Reconciles helper processes with currently active Wine prefixes."""
if not self.helperPath:
return True
now = time.monotonic()
discovered = self.discover_prefixes()
for prefix, (environment, loader) in discovered.items():
state = self.prefixes.setdefault(prefix, PrefixProcess())
state.lastSeen = now
self._ensure_running(prefix, state, environment, loader, now)
for prefix, state in list(self.prefixes.items()):
self._record_exit(state, now)
if prefix not in discovered and now - state.lastSeen >= self.IDLE_GRACE_SECONDS:
self._stop(state)
del self.prefixes[prefix]
return True
def _ensure_running(
self,
prefix: str,
state: PrefixProcess,
sourceEnvironment: dict[str, str],
loader: Optional[str],
now: float,
) -> None:
self._record_exit(state, now)
if state.process is not None or now < state.nextStart:
return
environment = os.environ.copy()
for key in ("DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"):
if sourceEnvironment.get(key):
environment[key] = sourceEnvironment[key]
environment["WINEPREFIX"] = prefix
environment[self.MANAGED_ENVIRONMENT_KEY] = "1"
if loader:
environment["WINELOADER"] = loader
command = [self.helperPath]
if not self.dialogReaderEnabled:
command.append("--no-dialog-reader")
if not self.controllerEnabled:
command.append("--no-controller")
try:
state.process = subprocess.Popen(
command,
env=environment,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
state.startedAt = now
debug.print_message(
debug.LEVEL_INFO, f"WINE ACCESS: Started helper for {prefix}", True
)
except OSError as error:
state.failures += 1
state.nextStart = now + min(2 ** state.failures, self.MAX_BACKOFF_SECONDS)
debug.print_message(
debug.LEVEL_WARNING, f"WINE ACCESS: Failed to start helper: {error}", True
)
def _record_exit(self, state: PrefixProcess, now: float) -> None:
if state.process is None or state.process.poll() is None:
return
runtime = now - state.startedAt
state.process = None
state.failures = state.failures + 1 if runtime < 10.0 else 0
state.nextStart = now + min(2 ** state.failures, self.MAX_BACKOFF_SECONDS)
@staticmethod
def _stop(state: PrefixProcess) -> None:
if state.process is None or state.process.poll() is not None:
state.process = None
return
state.process.terminate()
try:
state.process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
state.process.kill()
state.process = None
def shutdown(self) -> None:
"""Stops all managed helper processes."""
for state in self.prefixes.values():
self._stop(state)
self.prefixes.clear()
@@ -7,13 +7,33 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import cthulhu as cthulhu_package
from cthulhu import cthulhu_state
from cthulhu import cthulhu
from cthulhu import compositor_state_adapter
from cthulhu import compositor_state_types
from cthulhu import compositor_state_wayland
stubCthulhu = types.ModuleType("cthulhu.cthulhu")
stubCthulhu.cthulhuApp = mock.Mock()
previousCthulhuModule = sys.modules.get("cthulhu.cthulhu")
sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu)
from cthulhu import event_manager
from cthulhu.wayland_protocols import ext_workspace_v1
if previousCthulhuModule is None:
sys.modules.pop("cthulhu.cthulhu", None)
if getattr(cthulhu_package, "cthulhu", None) is stubCthulhu:
delattr(cthulhu_package, "cthulhu")
from cthulhu import cthulhu as restoredCthulhuModule
else:
sys.modules["cthulhu.cthulhu"] = previousCthulhuModule
cthulhu_package.cthulhu = previousCthulhuModule
restoredCthulhuModule = previousCthulhuModule
event_manager.cthulhu = restoredCthulhuModule
class FakeWorkspaceBackend:
def __init__(self, available: bool, name: str) -> None:
@@ -313,23 +333,38 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
adapter = mock.Mock()
adapter.sync_accessible_context = mock.Mock(return_value=None)
listener = mock.Mock()
fakeAtspi = types.SimpleNamespace(
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
)
fakeCthulhu = types.SimpleNamespace(
setActiveWindow=mock.Mock(),
setLocusOfFocus=mock.Mock(),
)
window = object()
focusedObject = object()
with (
mock.patch.object(cthulhu.event_manager.Atspi.EventListener, "new", return_value=listener),
mock.patch.object(cthulhu.event_manager.AXUtilities, "can_be_active_window", return_value=False),
mock.patch.object(cthulhu.event_manager.AXUtilities, "find_active_window", return_value=window),
mock.patch.object(cthulhu.event_manager.AXUtilities, "get_focused_object", return_value=focusedObject),
mock.patch.object(cthulhu.event_manager.cthulhu, "setActiveWindow") as setActiveWindow,
mock.patch.object(cthulhu.event_manager.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
mock.patch.object(event_manager, "Atspi", fakeAtspi),
mock.patch.object(event_manager.AXUtilities, "can_be_active_window", return_value=False),
mock.patch.object(event_manager.AXUtilities, "find_active_window", return_value=window),
mock.patch.object(event_manager.AXUtilities, "get_focused_object", return_value=focusedObject),
mock.patch.object(event_manager, "cthulhu", fakeCthulhu),
):
manager = cthulhu.event_manager.EventManager(mock.Mock())
manager = event_manager.EventManager(mock.Mock())
manager.set_compositor_state_adapter(adapter)
manager._sync_focus_on_startup()
setActiveWindow.assert_called_once_with(window, alsoSetLocusOfFocus=True, notifyScript=False)
setLocusOfFocus.assert_called_once_with(None, focusedObject, notifyScript=True, force=True)
fakeCthulhu.setActiveWindow.assert_called_once_with(
window,
alsoSetLocusOfFocus=True,
notifyScript=False,
)
fakeCthulhu.setLocusOfFocus.assert_called_once_with(
None,
focusedObject,
notifyScript=True,
force=True,
)
adapter.sync_accessible_context.assert_called_once_with("event-manager-startup")
+55 -11
View File
@@ -6,18 +6,62 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager")
input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub
import cthulhu as cthulhuPackage
from cthulhu.plugin_system_manager import PluginSystemManager
from cthulhu import cthulhu_state
from cthulhu import speech
from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode
from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine
from cthulhu.plugins.CthulhuRemote.plugin import CthulhuRemote
from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType
from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer
missingModule = object()
previousInputEventManagerModule = sys.modules.get(
"cthulhu.input_event_manager",
missingModule,
)
previousInputEventManagerAttribute = getattr(
cthulhuPackage,
"input_event_manager",
missingModule,
)
previousPluginSystemManagerModule = sys.modules.get(
"cthulhu.plugin_system_manager",
missingModule,
)
previousPluginSystemManagerAttribute = getattr(
cthulhuPackage,
"plugin_system_manager",
missingModule,
)
inputEventManagerStub = types.ModuleType("cthulhu.input_event_manager")
inputEventManagerStub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = inputEventManagerStub
cthulhuPackage.input_event_manager = inputEventManagerStub
try:
from cthulhu import plugin_system_manager
from cthulhu import cthulhu_state
from cthulhu import speech
from cthulhu.plugin_system_manager import PluginSystemManager
from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode
from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine
from cthulhu.plugins.CthulhuRemote.plugin import CthulhuRemote
from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType
from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer
finally:
if previousInputEventManagerModule is missingModule:
sys.modules.pop("cthulhu.input_event_manager", None)
else:
sys.modules["cthulhu.input_event_manager"] = previousInputEventManagerModule
if previousInputEventManagerAttribute is missingModule:
delattr(cthulhuPackage, "input_event_manager")
else:
cthulhuPackage.input_event_manager = previousInputEventManagerAttribute
if previousPluginSystemManagerModule is missingModule:
sys.modules.pop("cthulhu.plugin_system_manager", None)
else:
sys.modules["cthulhu.plugin_system_manager"] = previousPluginSystemManagerModule
if previousPluginSystemManagerAttribute is missingModule:
delattr(cthulhuPackage, "plugin_system_manager")
else:
cthulhuPackage.plugin_system_manager = previousPluginSystemManagerAttribute
class CthulhuRemotePluginTests(unittest.TestCase):
@@ -119,6 +119,243 @@ class NativeRemoteControllerTest(unittest.TestCase):
self.assertEqual(dbus_service.CthulhuRemoteController.SERVICE_NAME, "org.stormux.Cthulhu1.Service")
self.assertEqual(dbus_service.CthulhuRemoteController.OBJECT_PATH, "/org/stormux/Cthulhu1/Service")
@mock.patch("cthulhu.speech.speak")
def test_speak_text_uses_direct_speech_output(self, speak):
interface = dbus_service.CthulhuDBusServiceInterface()
self.assertTrue(interface.SpeakText("hello", True))
speak.assert_called_once_with("hello", interrupt=True)
@mock.patch("cthulhu.braille.displayMessage")
def test_braille_message_uses_persistent_flash(self, display_message):
interface = dbus_service.CthulhuDBusServiceInterface()
self.assertTrue(interface.BrailleMessage("hello"))
display_message.assert_called_once_with("hello", flashTime=-1)
@mock.patch("cthulhu.speech.stop")
def test_cancel_speech_stops_output(self, stop):
interface = dbus_service.CthulhuDBusServiceInterface()
self.assertTrue(interface.CancelSpeech())
stop.assert_called_once_with()
def test_ssml_fails_closed(self):
interface = dbus_service.CthulhuDBusServiceInterface()
self.assertEqual(interface.SpeakSsml("request", "<speak>Hello</speak>"), 50)
self.assertEqual(interface.SpeakSsml("request", "not xml"), 87)
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_normalizes_useful_fields(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
result = interface.PresentAccessibleEvent(
"source", "focus", "Open", "button", "", "focused", "", 2, 4, "Settings"
)
self.assertTrue(result)
present_message.assert_not_called()
interface._present_pending_accessible_focus()
present_message.assert_called_once_with("Open, button, 2 of 4, Settings")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_ignores_changes_from_unfocused_objects(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Open", "button", "", "", "", 0, 0, "Settings"
)
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"control-2", "value", "Cancel", "button", "", "", "", 0, 0, "Settings"
)
self.assertFalse(result)
present_message.assert_not_called()
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_presents_focused_value_changes_without_repeating_title(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Version", "combo box", "Windows 10", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"control-1", "value", "Version", "combo box", "Windows 11", "", "", 0, 0,
"Wine configuration"
)
self.assertTrue(result)
present_message.assert_called_once_with("Version, combo box, Windows 11")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_suppresses_immediate_duplicates(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
first = interface.PresentAccessibleEvent(
"control-1", "focus", "OK", "button", "", "", "", 0, 0, "Settings"
)
interface._present_pending_accessible_focus()
second = interface.PresentAccessibleEvent(
"control-1", "focus", "OK", "button", "", "", "", 0, 0, "Settings"
)
self.assertTrue(first)
self.assertFalse(second)
present_message.assert_called_once_with("OK, button, Settings")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_focus_burst_presents_only_final_control(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Add application", "button", "", "", "", 0, 0,
"Wine configuration"
)
interface.PresentAccessibleEvent(
"control-2", "focus", "OK", "button", "", "", "", 0, 0,
"Wine configuration"
)
interface.PresentAccessibleEvent(
"control-1", "focus", "Add application", "button", "", "", "", 0, 0,
"Wine configuration"
)
present_message.assert_not_called()
interface._present_pending_accessible_focus()
present_message.assert_called_once_with(
"Add application, button, Wine configuration"
)
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_focus_duplicate_clears_stale_pending_focus(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Open", "button", "", "", "", 0, 0,
"Settings"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
interface.PresentAccessibleEvent(
"control-2", "focus", "Cancel", "button", "", "", "", 0, 0,
"Settings"
)
duplicate = interface.PresentAccessibleEvent(
"control-1", "focus", "Open", "button", "", "", "", 0, 0,
"Settings"
)
interface._present_pending_accessible_focus()
self.assertFalse(duplicate)
self.assertEqual(interface._wineAccessibleFocusSource, "control-1")
present_message.assert_not_called()
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_focus_presents_new_window_title_without_control_fields(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
result = interface.PresentAccessibleEvent(
"window", "focus", "", "", "", "", "", 0, 0, "Wine configuration"
)
interface._present_pending_accessible_focus()
self.assertTrue(result)
present_message.assert_called_once_with("Wine configuration")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_omits_generic_client_role(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "OK", "client", "", "", "", 0, 0, "Settings"
)
interface._present_pending_accessible_focus()
present_message.assert_called_once_with("OK, Settings")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_ignores_name_and_state_noise_on_focused_object(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Windows 10", "client", "", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
name_result = interface.PresentAccessibleEvent(
"control-1", "name", "OK", "client", "", "", "", 0, 0,
"Wine configuration"
)
state_result = interface.PresentAccessibleEvent(
"control-1", "state", "OK", "client", "", "focused", "", 0, 0,
"Wine configuration"
)
self.assertFalse(name_result)
self.assertFalse(state_result)
present_message.assert_not_called()
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_accepts_selection_from_focused_combo_child(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"combo", "focus", "Windows 10", "client", "", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"combo-item-2", "selection", "Windows 8.1", "client", "", "", "", 0, 0,
"Wine configuration"
)
self.assertTrue(result)
present_message.assert_called_once_with("Windows 8.1")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_rejects_selection_from_other_window(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"combo", "focus", "Windows 10", "client", "", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"other-item", "selection", "Unrelated", "client", "", "", "", 0, 0,
"Other window"
)
self.assertFalse(result)
present_message.assert_not_called()
if __name__ == "__main__":
unittest.main()
+9 -3
View File
@@ -30,6 +30,7 @@ sys.modules.setdefault(
),
)
from cthulhu import ax_document_selection
from cthulhu.script_utilities import Utilities
@@ -131,9 +132,14 @@ class DocumentSelectionRegressionTests(unittest.TestCase):
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.set_selected_text", set_selected_text))
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._get_n_selections", return_value=1))
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._remove_selection", remove_selection))
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.get_text_selections", side_effect=get_text_selections))
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.set_text_selections", side_effect=set_text_selections))
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.TextSelection", side_effect=self._new_text_selection))
fakeAtspi = types.SimpleNamespace(
Document=types.SimpleNamespace(
get_text_selections=mock.Mock(side_effect=get_text_selections),
set_text_selections=mock.Mock(side_effect=set_text_selections),
),
TextSelection=mock.Mock(side_effect=self._new_text_selection),
)
stack.enter_context(mock.patch.object(ax_document_selection, "Atspi", fakeAtspi))
stack.enter_context(
mock.patch(
"cthulhu.script_utilities.cthulhu.cthulhuApp",
@@ -34,11 +34,12 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
self.addCleanup(self._restore_cthulhu_state)
self.listener = mock.Mock()
self.listenerPatch = mock.patch.object(
event_manager.Atspi.EventListener,
"new",
return_value=self.listener,
fakeAtspi = types.SimpleNamespace(
Accessible=event_manager.Atspi.Accessible,
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=self.listener)),
Role=event_manager.Atspi.Role,
)
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop)
@@ -27,11 +27,12 @@ class FakeEvent:
class EventManagerRelevanceGateRegressionTests(unittest.TestCase):
def setUp(self) -> None:
self.listener = mock.Mock()
self.listenerPatch = mock.patch.object(
event_manager.Atspi.EventListener,
"new",
return_value=self.listener,
fakeAtspi = types.SimpleNamespace(
Accessible=event_manager.Atspi.Accessible,
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=self.listener)),
Role=event_manager.Atspi.Role,
)
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop)
@@ -0,0 +1,310 @@
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import input_event_manager
class FakeDevice:
def __init__(self) -> None:
self.add_key_watcher_calls = []
self.connect_calls = []
self.disconnect_calls = []
self.next_handler_id = 17
self.add_key_grab_calls = []
self.grab_ids = []
def add_key_watcher(self, callback: object, user_data: object = None) -> None:
self.add_key_watcher_calls.append((callback, user_data))
def connect(self, signalName: str, callback: object) -> int:
self.connect_calls.append((signalName, callback))
handlerId = self.next_handler_id
self.next_handler_id += 1
return handlerId
def disconnect(self, handlerId: int) -> None:
self.disconnect_calls.append(handlerId)
def add_key_grab(self, definition: object, callback: object = None) -> int:
self.add_key_grab_calls.append((definition, callback))
return self.grab_ids.pop(0)
class FakeDeviceFactory:
def __init__(self, device: FakeDevice) -> None:
self.device = device
def new(self) -> FakeDevice:
return self.device
def new_full(self, _appId: str) -> FakeDevice:
return self.device
class InputEventManagerKeyWatcherTests(unittest.TestCase):
@staticmethod
def _fake_atspi(
version: tuple[int, int, int],
device: FakeDevice,
) -> types.SimpleNamespace:
return types.SimpleNamespace(
get_version=lambda: version,
Device=FakeDeviceFactory(device),
)
def test_atspi_260_uses_key_signals_and_disconnects_them(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 60, 5), device)
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "wayland"}),
):
manager.start_key_watcher()
manager.stop_key_watcher()
self.assertEqual(
device.connect_calls,
[
("key-pressed", manager._on_key_pressed),
("key-released", manager._on_key_released),
],
)
self.assertEqual(device.add_key_watcher_calls, [])
self.assertEqual(device.disconnect_calls, [17, 18])
self.assertIsNone(manager.get_device())
def test_pre_atspi_260_keeps_legacy_key_watcher(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 58, 4), device)
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "wayland"}),
):
manager.start_key_watcher()
self.assertEqual(
device.add_key_watcher_calls,
[(manager.process_keyboard_event, None)],
)
self.assertEqual(device.connect_calls, [])
def test_key_signal_callbacks_preserve_pressed_state(self) -> None:
manager = input_event_manager.InputEventManager()
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 56, 98, 0, "b")
manager._on_key_released(device, 56, 98, 0, "b")
self.assertEqual(
processEvent.call_args_list,
[
mock.call(device, True, 56, 98, 0, "b"),
mock.call(device, False, 56, 98, 0, "b"),
],
)
def test_x11_consumable_listener_includes_locking_modifier_combinations(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 60, 5), device)
fakeAtspi.EventType = types.SimpleNamespace(KEY_PRESSED_EVENT=0, KEY_RELEASED_EVENT=1)
fakeKeySet = [types.SimpleNamespace(keycode=38), types.SimpleNamespace(keycode=56)]
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "x11"}),
mock.patch.object(
input_event_manager,
"_get_x11_consumable_key_set",
return_value=fakeKeySet,
),
mock.patch.object(input_event_manager.pyatspi.Registry, "registerKeystrokeListener") as register,
mock.patch.object(input_event_manager.pyatspi.Registry, "deregisterKeystrokeListener") as deregister,
):
manager.start_key_watcher()
manager.stop_key_watcher()
shiftMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFT)
shiftLockMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFTLOCK)
numLockMask = 1 << int(input_event_manager.Atspi.ModifierType.NUMLOCK)
expectedMasks = [
0,
shiftMask,
shiftLockMask,
shiftMask | shiftLockMask,
numLockMask,
shiftMask | numLockMask,
shiftLockMask | numLockMask,
shiftMask | shiftLockMask | numLockMask,
]
register.assert_called_once_with(
manager._process_selective_legacy_keyboard_event,
key_set=fakeKeySet,
mask=expectedMasks,
kind=(0, 1),
synchronous=True,
preemptive=True,
)
deregister.assert_called_once_with(
manager._process_selective_legacy_keyboard_event,
key_set=fakeKeySet,
mask=expectedMasks,
kind=(0, 1),
)
self.assertEqual(
device.connect_calls,
[
("key-pressed", manager._on_key_pressed),
("key-released", manager._on_key_released),
],
)
def test_x11_consumable_keycodes_include_structural_navigation_but_not_tab(self) -> None:
keycodes = {
keysym: index + 20
for index, keysym in enumerate(input_event_manager._X11_CONSUMABLE_KEYSYMS)
}
with mock.patch("cthulhu.keybindings.get_keycodes") as getKeycodes:
getKeycodes.side_effect = lambda keysym: (0, keycodes[keysym])
result = input_event_manager._get_x11_consumable_key_set()
resultKeycodes = {definition.keycode for definition in result}
self.assertIn(keycodes["b"], resultKeycodes)
self.assertIn(keycodes["h"], resultKeycodes)
requestedKeysyms = {call.args[0] for call in getKeycodes.call_args_list}
self.assertNotIn("Tab", requestedKeysyms)
def test_x11_consumable_key_set_uses_atspi_key_definitions(self) -> None:
with mock.patch("cthulhu.keybindings.get_keycodes", return_value=(0, 38)):
keySet = input_event_manager._get_x11_consumable_key_set()
self.assertTrue(keySet)
self.assertTrue(all(isinstance(item, input_event_manager.Atspi.KeyDefinition) for item in keySet))
def test_modern_signal_defers_registered_x11_key_to_consumable_listener(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 56, 98, 0, "b")
manager._on_key_released(device, 56, 98, 0, "b")
processEvent.assert_not_called()
def test_modern_signal_defers_x11_key_with_locking_modifiers(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
shiftMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFT)
shiftLockMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFTLOCK)
numLockMask = 1 << int(input_event_manager.Atspi.ModifierType.NUMLOCK)
lockingMasks = (
shiftLockMask,
shiftMask | shiftLockMask,
numLockMask,
shiftMask | numLockMask,
shiftLockMask | numLockMask,
shiftMask | shiftLockMask | numLockMask,
)
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
for modifiers in lockingMasks:
manager._on_key_pressed(device, 56, 98, modifiers, "b")
manager._on_key_released(device, 56, 98, modifiers, "b")
processEvent.assert_not_called()
def test_modern_signal_still_processes_keys_not_owned_by_selective_listener(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 23, 65289, 0, "Tab")
manager._on_key_released(device, 23, 65289, 0, "Tab")
self.assertEqual(
processEvent.call_args_list,
[
mock.call(device, True, 23, 65289, 0, "Tab"),
mock.call(device, False, 23, 65289, 0, "Tab"),
],
)
def test_keyboard_event_returns_actual_consumption_decision(self) -> None:
manager = input_event_manager.InputEventManager()
focusManager = mock.Mock()
focusManager.get_active_window.return_value = object()
focusManager.get_locus_of_focus.return_value = object()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = mock.Mock()
keyboardEvent = mock.Mock()
keyboardEvent.is_modifier_key.return_value = False
keyboardEvent.process.return_value = False
with (
mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False),
mock.patch.object(input_event_manager.cthulhu_state, "pendingSelfHostedFocus", None),
mock.patch.object(input_event_manager.focus_manager, "get_manager", return_value=focusManager),
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent),
mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False),
mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=True),
mock.patch.object(manager, "last_event_was_keyboard", return_value=False),
):
result = manager.process_keyboard_event(mock.Mock(), True, 56, 98, 0, "b")
self.assertFalse(result)
keyboardEvent.process.assert_called_once_with()
def test_x11_consumable_listener_is_only_owner_of_matching_key_grabs(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
device.grab_ids = [101, 102, 103]
manager._device = device
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
legacyOwned = types.SimpleNamespace(keycode=0, keysym=98, modifiers=0)
modified = types.SimpleNamespace(keycode=0, keysym=98, modifiers=4)
unrelated = types.SimpleNamespace(keycode=0, keysym=106, modifiers=0)
binding = mock.Mock()
binding.is_enabled.return_value = True
binding.is_bound.return_value = True
binding.has_grabs.return_value = False
binding.keycode = 56
binding.key_definitions.return_value = [legacyOwned, modified]
unrelatedBinding = mock.Mock()
unrelatedBinding.is_enabled.return_value = True
unrelatedBinding.is_bound.return_value = True
unrelatedBinding.has_grabs.return_value = False
unrelatedBinding.keycode = 44
unrelatedBinding.key_definitions.return_value = [unrelated]
result = manager.add_grabs_for_keybinding(binding)
unrelatedResult = manager.add_grabs_for_keybinding(unrelatedBinding)
self.assertEqual(result, [101])
self.assertEqual(unrelatedResult, [102])
self.assertEqual(
device.add_key_grab_calls,
[(modified, None), (unrelated, None)],
)
if __name__ == "__main__":
unittest.main()
@@ -2,6 +2,7 @@ import sys
import types
import unittest
from pathlib import Path
from typing import Any
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -133,6 +134,20 @@ class InputEventManagerPointerMonitorTests(unittest.TestCase):
class MouseReviewBackendSelectionTests(unittest.TestCase):
@staticmethod
def _make_atspi(
version: tuple[int, int, int],
listener: Any,
pointerMonitor: int | None = None,
) -> types.SimpleNamespace:
fakeAtspi = types.SimpleNamespace(
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
get_version=mock.Mock(return_value=version),
)
if pointerMonitor is not None:
fakeAtspi.DeviceCapability = types.SimpleNamespace(POINTER_MONITOR=pointerMonitor)
return fakeAtspi
@staticmethod
def _make_app(enabled=False):
app = mock.Mock()
@@ -146,16 +161,10 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
listener = mock.Mock()
deviceManager = mock.Mock()
deviceManager.enable_pointer_monitoring.return_value = True
fakeAtspi = self._make_atspi((2, 60, 0), listener, pointerMonitor=8)
with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 60, 0)),
mock.patch.object(
mouse_review.Atspi,
"DeviceCapability",
new=types.SimpleNamespace(POINTER_MONITOR=8),
create=True,
),
mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review, "Wnck", None),
):
@@ -170,16 +179,10 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
listener = mock.Mock()
deviceManager = mock.Mock()
deviceManager.enable_pointer_monitoring.return_value = True
fakeAtspi = self._make_atspi((2, 60, 0), listener, pointerMonitor=8)
with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 60, 0)),
mock.patch.object(
mouse_review.Atspi,
"DeviceCapability",
new=types.SimpleNamespace(POINTER_MONITOR=8),
create=True,
),
mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review, "Wnck", None),
@@ -202,14 +205,19 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
screen.get_active_workspace.return_value = None
fakeWnck = mock.Mock()
fakeWnck.Screen.get_default.return_value = screen
fakeAtspi = self._make_atspi((2, 58, 4), listener)
fakeGdk = types.SimpleNamespace(
Display=types.SimpleNamespace(
get_default=mock.Mock(return_value=mock.Mock()),
get_default_seat=mock.Mock(return_value=seat),
),
)
with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 58, 4)),
mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.input_event_manager, "get_manager"),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review.Gdk.Display, "get_default", return_value=mock.Mock()),
mock.patch.object(mouse_review.Gdk.Display, "get_default_seat", return_value=seat),
mock.patch.object(mouse_review, "Gdk", fakeGdk),
mock.patch.object(mouse_review, "Wnck", fakeWnck),
):
reviewer = mouse_review.MouseReviewer(self._make_app(enabled=False))
@@ -8,11 +8,55 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager")
input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub
import cthulhu as cthulhuPackage
from cthulhu.plugin_system_manager import PluginInfo, PluginSystemManager
missingModule = object()
previousInputEventManagerModule = sys.modules.get(
"cthulhu.input_event_manager",
missingModule,
)
previousInputEventManagerAttribute = getattr(
cthulhuPackage,
"input_event_manager",
missingModule,
)
previousPluginSystemManagerModule = sys.modules.get(
"cthulhu.plugin_system_manager",
missingModule,
)
previousPluginSystemManagerAttribute = getattr(
cthulhuPackage,
"plugin_system_manager",
missingModule,
)
inputEventManagerStub = types.ModuleType("cthulhu.input_event_manager")
inputEventManagerStub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = inputEventManagerStub
cthulhuPackage.input_event_manager = inputEventManagerStub
try:
from cthulhu import plugin_system_manager
from cthulhu.plugin_system_manager import PluginInfo, PluginSystemManager
finally:
if previousInputEventManagerModule is missingModule:
sys.modules.pop("cthulhu.input_event_manager", None)
else:
sys.modules["cthulhu.input_event_manager"] = previousInputEventManagerModule
if previousInputEventManagerAttribute is missingModule:
delattr(cthulhuPackage, "input_event_manager")
else:
cthulhuPackage.input_event_manager = previousInputEventManagerAttribute
if previousPluginSystemManagerModule is missingModule:
sys.modules.pop("cthulhu.plugin_system_manager", None)
else:
sys.modules["cthulhu.plugin_system_manager"] = previousPluginSystemManagerModule
if previousPluginSystemManagerAttribute is missingModule:
delattr(cthulhuPackage, "plugin_system_manager")
else:
cthulhuPackage.plugin_system_manager = previousPluginSystemManagerAttribute
PLUGIN_TEMPLATE = """
+140 -2
View File
@@ -8,6 +8,9 @@ import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -20,9 +23,62 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
from cthulhu import cthulhu_state
from cthulhu.scripts.apps.steamwebhelper import script as steam_script
class SteamInactiveNotificationContractTests(unittest.TestCase):
@staticmethod
def _prepare_event_script(testScript, presentIfInactive):
handler = mock.Mock()
testScript.presentIfInactive = presentIfInactive
testScript.listeners = {"object:state-changed:showing": handler}
testScript.generatorCache = {"stale": object()}
testScript.skipObjectEvent = mock.Mock(return_value=False)
return handler
def test_steam_notification_is_processed_while_script_is_inactive(self):
app = object()
def initialize_chromium(testScript, initializedApp):
testScript.app = initializedApp
testScript.presentIfInactive = False
with mock.patch.object(
steam_script.Chromium.Script,
"__init__",
new=initialize_chromium,
):
testScript = steam_script.Script(app)
handler = self._prepare_event_script(testScript, testScript.presentIfInactive)
event = mock.Mock(type="object:state-changed:showing", source=object())
with (
mock.patch.object(cthulhu_state, "activeScript", object()),
mock.patch.object(AXObject, "get_role", return_value=Atspi.Role.NOTIFICATION),
):
testScript.processObjectEvent(event)
self.assertTrue(testScript.presentIfInactive)
handler.assert_called_once_with(event)
def test_generic_chromium_notification_is_deferred_while_script_is_inactive(self):
testScript = steam_script.Chromium.Script.__new__(
steam_script.Chromium.Script
)
handler = self._prepare_event_script(testScript, presentIfInactive=False)
event = mock.Mock(type="object:state-changed:showing", source=object())
with (
mock.patch.object(cthulhu_state, "activeScript", object()),
mock.patch.object(AXObject, "get_role", return_value=Atspi.Role.NOTIFICATION),
):
testScript.processObjectEvent(event)
handler.assert_not_called()
class SteamNotificationRootTests(unittest.TestCase):
def test_prefers_notification_ancestor_over_live_region_descendant(self):
testScript = steam_script.Script.__new__(steam_script.Script)
@@ -50,11 +106,55 @@ class SteamNotificationRootTests(unittest.TestCase):
self.assertIs(result, notification)
class SteamNotificationQueueTests(unittest.TestCase):
def test_merges_non_overlapping_fragments_for_same_notification(self):
class SteamNotificationRoutingContractTests(unittest.TestCase):
def test_steam_notification_override_handles_showing_before_chromium(self):
testScript = steam_script.Script.__new__(steam_script.Script)
notification = object()
event = mock.Mock(source=notification, detail1=1)
testScript._presentSteamNotification = mock.Mock()
with (
mock.patch.object(
testScript,
"_isSteamNotification",
return_value=True,
),
mock.patch.object(
steam_script.Chromium.Script,
"onShowingChanged",
) as chromiumHandler,
):
testScript.onShowingChanged(event)
testScript._presentSteamNotification.assert_called_once_with(notification)
chromiumHandler.assert_not_called()
def test_nonsteam_showing_event_defers_to_chromium(self):
testScript = steam_script.Script.__new__(steam_script.Script)
event = mock.Mock(source=object(), detail1=1)
testScript._presentSteamNotification = mock.Mock()
with (
mock.patch.object(
testScript,
"_isSteamNotification",
return_value=False,
),
mock.patch.object(
steam_script.Chromium.Script,
"onShowingChanged",
) as chromiumHandler,
):
testScript.onShowingChanged(event)
testScript._presentSteamNotification.assert_not_called()
chromiumHandler.assert_called_once_with(event)
class SteamNotificationQueueTests(unittest.TestCase):
@staticmethod
def _make_script():
testScript = steam_script.Script.__new__(steam_script.Script)
testScript._lastSteamNotification = ("", 0.0)
testScript._steamPendingNotification = None
testScript._steamRelativeTimePattern = re.compile(
@@ -63,6 +163,11 @@ class SteamNotificationQueueTests(unittest.TestCase):
)
testScript._resetSteamPendingTimer = mock.Mock()
testScript._presentSteamNotificationTextNow = mock.Mock()
return testScript
def test_merges_non_overlapping_fragments_for_same_notification(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("username", notification)
testScript._queueSteamNotification("Playing: Borderlands 2", notification)
@@ -80,6 +185,39 @@ class SteamNotificationQueueTests(unittest.TestCase):
notification,
)
def test_merges_relative_timestamp_without_repeated_presentation(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("Username is now online", notification)
testScript._queueSteamNotification("2 minutes ago", notification)
testScript._presentSteamNotificationTextNow.assert_not_called()
self.assertEqual(
testScript._steamPendingNotification["text"],
"Username is now online. 2 minutes ago",
)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"Username is now online. 2 minutes ago",
notification,
)
def test_duplicate_complete_notification_is_presented_once(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("Username is now online", notification)
testScript._queueSteamNotification("Username is now online", notification)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"Username is now online",
notification,
)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -569,6 +569,38 @@ class SteamReturnActivationTests(unittest.TestCase):
self.assertEqual(testScript._steamFriendsPanelActivationTarget, (None, "", 0.0))
def test_return_rejects_friends_target_after_nonsteam_x11_handoff(self):
testScript = steam_script.Script.__new__(steam_script.Script)
button = object()
keyboardEvent = mock.Mock(event_string="Return", modifiers=0)
keyboardEvent.is_pressed_key.return_value = True
testScript.utilities = mock.Mock()
testScript._steamActiveQuickAccessTab = "Friends"
testScript._steamFriendsPanelActivationTarget = (
button,
"Username Playing Terraria",
100.0,
)
testScript._steamFriendsPanelActivationTargetMaxAge = 10.0
with (
mock.patch.object(steam_script.time, "monotonic", return_value=105.0),
mock.patch.object(
steam_script.Script,
"_activeX11WindowIsSteamSelectionContext",
return_value=False,
),
mock.patch.object(testScript, "_getClickableActivationTarget", return_value=None),
mock.patch.object(
steam_script.Script,
"_performClickableAction",
) as performAction,
):
self.assertFalse(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None))
performAction.assert_not_called()
class SteamVirtualizedListMutationTests(unittest.TestCase):
def test_name_changed_presents_friends_panel_item_after_friends_tab_activation(self):
@@ -64,7 +64,11 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase):
settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP,
)
self.assertEqual(general["cthulhuModifierKeys"], settings.DESKTOP_MODIFIER_KEYS)
self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"])
self.assertEqual(
general["activePlugins"],
["Clipboard", "OCR", "WineAccessibility"],
)
self.assertTrue(general["wineAccessibilityPluginMigrated"])
self.assertEqual(general["aiProvider"], settings.AI_PROVIDER_OLLAMA)
self.assertFalse(general["aiAssistantEnabled"])
self.assertEqual(general["ocrLanguageCode"], "eng")
@@ -82,10 +86,35 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase):
savedSettings = settingsPath.read_text(encoding="utf-8")
self.assertIn('profile = ["Default", "default"]', savedSettings)
self.assertIn('activePlugins = ["Clipboard", "OCR"]', savedSettings)
self.assertIn(
'activePlugins = ["Clipboard", "OCR", "WineAccessibility"]',
savedSettings,
)
self.assertIn("wineAccessibilityPluginMigrated = true", savedSettings)
self.assertNotIn("format-version = 2", savedSettings)
self.assertNotIn("[profiles.default.metadata]", savedSettings)
def test_wine_accessibility_migration_respects_later_plugin_disable(self):
migratedSettings = """[general]
startingProfile = ["Default", "default"]
[profiles.default]
profile = ["Default", "default"]
activePlugins = ["Clipboard", "OCR"]
wineAccessibilityPluginMigrated = true
"""
with tempfile.TemporaryDirectory() as tempDir:
Path(tempDir, "user-settings.toml").write_text(
migratedSettings,
encoding="utf-8",
)
backend = Backend(tempDir)
general = backend.getGeneral("default")
self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"])
def test_legacy_profile_keybindings_are_preserved(self):
legacySettings = LEGACY_SETTINGS.replace(
'desktop-modifier-keys = ["Insert", "KP_Insert"]',
File diff suppressed because it is too large Load Diff
+82 -5
View File
@@ -24,6 +24,7 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
from cthulhu import messages
from cthulhu.ax_utilities_event import AXUtilitiesEvent, TextEventReason
from cthulhu.scripts.web import script as web_script
from cthulhu.scripts.web import script_utilities as web_script_utilities
from cthulhu.scripts.toolkits.Gecko import script_utilities as gecko_script_utilities
@@ -308,7 +309,6 @@ class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase):
testScript.updateBraille = mock.Mock()
testScript.presentationInterrupt = mock.Mock()
testScript._saveFocusedObjectInfo = mock.Mock()
testScript.refreshKeyGrabs = mock.Mock()
return testScript
def test_focus_generated_speech_uses_explicit_interrupt_then_appends(self):
@@ -336,6 +336,7 @@ class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase):
class WebCaretMovedRegressionTests(unittest.TestCase):
def _make_script(self):
testScript = web_script.Script.__new__(web_script.Script)
testScript._inFocusMode = False
testScript._lastCommandWasCaretNav = False
testScript._lastCommandWasStructNav = False
testScript._lastCommandWasMouseButton = False
@@ -351,15 +352,87 @@ class WebCaretMovedRegressionTests(unittest.TestCase):
def test_matching_char_nav_caret_event_is_consumed_without_duplicate_presentation(self):
testScript = self._make_script()
source = "source"
event = mock.Mock(source=source, detail1=4)
event = mock.Mock(type="object:text-caret-moved", source=source, detail1=4)
with mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus:
with (
mock.patch.object(
AXUtilitiesEvent,
"get_text_event_reason",
return_value=TextEventReason.NAVIGATION_BY_CHARACTER,
),
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onCaretMoved(testScript, event)
self.assertTrue(result)
testScript.utilities.setCaretContext.assert_not_called()
setLocusOfFocus.assert_not_called()
def test_focus_change_caret_event_does_not_replace_real_focus_event(self):
testScript = self._make_script()
source = object()
event = mock.Mock(
type="object:text-caret-moved",
source=source,
detail1=0,
)
testScript.utilities.getCaretContext.return_value = ("old-focus", 0)
testScript.utilities.lastInputEventWasCharNav.return_value = False
with (
mock.patch.object(
AXUtilitiesEvent,
"get_text_event_reason",
return_value=TextEventReason.FOCUS_CHANGE,
) as getReason,
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onCaretMoved(testScript, event)
self.assertTrue(result)
getReason.assert_called_once_with(event)
testScript.utilities.setCaretContext.assert_not_called()
setLocusOfFocus.assert_not_called()
class TextEventReasonRegressionTests(unittest.TestCase):
def test_tab_is_classified_as_focus_change_before_editable_state(self):
oldFocus = object()
source = object()
event = mock.Mock(type="object:text-caret-moved", source=source)
inputManager = mock.Mock()
inputManager.last_event_was_caret_selection.return_value = False
inputManager.last_event_was_caret_navigation.return_value = False
inputManager.last_event_was_select_all.return_value = False
inputManager.last_event_was_primary_click_or_release.return_value = False
inputManager.last_event_was_tab_navigation.return_value = True
with (
mock.patch(
"cthulhu.input_event_manager.get_manager",
return_value=inputManager,
),
mock.patch(
"cthulhu.ax_utilities_event.focus_manager.get_manager"
) as getFocusManager,
mock.patch(
"cthulhu.ax_utilities_event.AXUtilitiesRole.is_text_input_search",
return_value=False,
),
mock.patch(
"cthulhu.ax_utilities_event.AXUtilitiesState.is_editable",
return_value=True,
) as isEditable,
):
getFocusManager.return_value.get_active_mode_and_object_of_interest.return_value = (
None,
oldFocus,
)
reason = AXUtilitiesEvent._get_caret_moved_event_reason(event)
self.assertEqual(reason, TextEventReason.FOCUS_CHANGE)
isEditable.assert_not_called()
class WebDescriptionChangeRegressionTests(unittest.TestCase):
def _make_script(self):
@@ -419,6 +492,7 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
testScript.utilities.handleEventFromContextReplicant.return_value = False
testScript.utilities.handleEventForRemovedChild.return_value = False
testScript.utilities.inDocumentContent.return_value = True
testScript.refreshKeyGrabs = mock.Mock()
return testScript
def test_children_added_to_live_focus_preserves_caret_context(self):
@@ -512,7 +586,6 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
setLocusOfFocus.assert_called_once_with(event, link, False)
testScript.utilities.setCaretContext.assert_called_once_with(link, 0)
testScript.utilities.searchForCaretContext.assert_not_called()
def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self):
testScript = self._make_dynamic_script()
source = object()
@@ -610,7 +683,11 @@ class WebSelectionRegressionTests(unittest.TestCase):
testScript = self._make_script()
document = object()
section = object()
event = mock.Mock(source=section, detail1=-1)
event = mock.Mock(
type="object:text-caret-moved",
source=section,
detail1=-1,
)
manager = mock.Mock()
manager.last_event_was_forward_caret_selection.return_value = True
manager.last_event_was_backward_caret_selection.return_value = False
@@ -0,0 +1,24 @@
import re
import unittest
from pathlib import Path
class WineAccessControllerContractTests(unittest.TestCase):
def test_legacy_speak_text_queues_without_cancelling_current_speech(self):
sourcePath = Path(__file__).resolve().parents[1] / "wine-access" / "main.cpp"
source = sourcePath.read_text(encoding="utf-8")
match = re.search(
r'extern "C" error_status_t __stdcall speakText\(.*?\n\}',
source,
re.DOTALL,
)
self.assertIsNotNone(match)
self.assertIn(
'g_variant_new("(sb)", utf8.c_str(), FALSE)',
match.group(0),
)
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
import unittest
from unittest import mock
from cthulhu.wine_access_manager import PrefixProcess, WineAccessManager
class WineAccessManagerTest(unittest.TestCase):
def test_prefix_prefers_explicit_wineprefix(self):
environment = {
"WINEPREFIX": "/tmp/custom-prefix",
"STEAM_COMPAT_DATA_PATH": "/tmp/compat",
}
self.assertEqual(
WineAccessManager._prefix_from_environ(environment), "/tmp/custom-prefix"
)
def test_prefix_uses_proton_pfx(self):
environment = {"STEAM_COMPAT_DATA_PATH": "/tmp/compat"}
self.assertEqual(WineAccessManager._prefix_from_environ(environment), "/tmp/compat/pfx")
@mock.patch("cthulhu.wine_access_manager.os.access", return_value=True)
@mock.patch(
"cthulhu.wine_access_manager.os.readlink",
return_value="/opt/proton/files/bin/wine64-preloader",
)
def test_preloader_resolves_to_runtime_loader(self, _readlink, _access):
self.assertEqual(
WineAccessManager._loader_for_process(100, {}),
"/opt/proton/files/bin/wine64",
)
@mock.patch("cthulhu.wine_access_manager.subprocess.Popen")
def test_launch_preserves_prefix_and_runtime_loader(self, popen):
process = mock.Mock()
process.poll.return_value = None
popen.return_value = process
manager = WineAccessManager("/usr/bin/cthulhu-wine-access.exe")
state = PrefixProcess()
manager._ensure_running(
"/tmp/prefix",
state,
{"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus"},
"/opt/proton/files/bin/wine64",
10.0,
)
environment = popen.call_args.kwargs["env"]
self.assertEqual(environment["WINEPREFIX"], "/tmp/prefix")
self.assertEqual(environment["WINELOADER"], "/opt/proton/files/bin/wine64")
self.assertEqual(environment[manager.MANAGED_ENVIRONMENT_KEY], "1")
self.assertEqual(
environment["DBUS_SESSION_BUS_ADDRESS"], "unix:path=/run/user/1000/bus"
)
self.assertEqual(popen.call_args.args[0], ["/usr/bin/cthulhu-wine-access.exe"])
@mock.patch("cthulhu.wine_access_manager.subprocess.Popen")
def test_disabled_surfaces_are_passed_to_helper(self, popen):
process = mock.Mock()
process.poll.return_value = None
popen.return_value = process
manager = WineAccessManager("/usr/bin/cthulhu-wine-access.exe")
manager.dialogReaderEnabled = False
manager.controllerEnabled = False
manager._ensure_running("/tmp/prefix", PrefixProcess(), {}, None, 10.0)
self.assertEqual(
popen.call_args.args[0],
[
"/usr/bin/cthulhu-wine-access.exe",
"--no-dialog-reader",
"--no-controller",
],
)
def test_short_lived_helper_gets_restart_backoff(self):
manager = WineAccessManager("/usr/bin/cthulhu-wine-access.exe")
state = PrefixProcess()
state.process = mock.Mock()
state.process.poll.return_value = 0
state.startedAt = 9.0
manager._record_exit(state, 10.0)
self.assertIsNone(state.process)
self.assertEqual(state.failures, 1)
self.assertEqual(state.nextStart, 12.0)
@mock.patch.object(WineAccessManager, "_loader_for_process", return_value=None)
@mock.patch.object(
WineAccessManager,
"_read_environ",
return_value={"WINEPREFIX": "/tmp/exported-prefix"},
)
@mock.patch("cthulhu.wine_access_manager.Path.iterdir")
@mock.patch("cthulhu.wine_access_manager.os.getuid", return_value=1000)
def test_discovery_ignores_non_wine_processes(
self, _getuid, iterdir, _read_environ, _loader
):
entry = mock.Mock()
entry.name = "123"
entry.stat.return_value.st_uid = 1000
iterdir.return_value = [entry]
self.assertEqual(WineAccessManager("/tmp/helper").discover_prefixes(), {})
@mock.patch.object(WineAccessManager, "_loader_for_process")
@mock.patch.object(
WineAccessManager,
"_read_environ",
return_value={
"WINEPREFIX": "/tmp/prefix",
WineAccessManager.MANAGED_ENVIRONMENT_KEY: "1",
},
)
@mock.patch("cthulhu.wine_access_manager.Path.iterdir")
@mock.patch("cthulhu.wine_access_manager.os.getuid", return_value=1000)
def test_discovery_ignores_managed_helper_processes(
self, _getuid, iterdir, _read_environ, loader
):
entry = mock.Mock()
entry.name = "123"
entry.stat.return_value.st_uid = 1000
iterdir.return_value = [entry]
self.assertEqual(WineAccessManager("/tmp/helper").discover_prefixes(), {})
loader.assert_not_called()
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
cmake_minimum_required(VERSION 3.20)
project(cthulhu_wine_access LANGUAGES C CXX)
include(GNUInstallDirs)
find_package(PkgConfig REQUIRED)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
find_program(WIDL_EXECUTABLE widl REQUIRED)
set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
set(generated_header "${generated_dir}/nvdaController.h")
set(generated_server "${generated_dir}/nvdaController_s.c")
file(MAKE_DIRECTORY "${generated_dir}")
add_custom_command(
OUTPUT "${generated_header}" "${generated_server}"
COMMAND "${WIDL_EXECUTABLE}" --win64 -h -H "${generated_header}"
-s -S "${generated_server}" "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl"
VERBATIM)
add_executable(cthulhu-wine-access main.cpp "${generated_server}")
target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20)
target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}")
target_link_libraries(cthulhu-wine-access PRIVATE
PkgConfig::GIO oleacc ole32 oleaut32 rpcrt4 user32)
target_compile_options(cthulhu-wine-access PRIVATE
$<$<COMPILE_LANGUAGE:C,CXX>:-Wall -Wextra>)
install(TARGETS cthulhu-wine-access RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
+555
View File
@@ -0,0 +1,555 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "nvdaController.h"
#include <commctrl.h>
#include <gio/gio.h>
#include <oleacc.h>
#include <rpc.h>
#include <windows.h>
#include <cstdint>
#include <cstdlib>
#include <cwchar>
#include <iterator>
#include <string>
namespace {
constexpr auto BUS_NAME = "org.stormux.Cthulhu1.Service";
constexpr auto OBJECT_PATH = "/org/stormux/Cthulhu1/Service";
constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service";
constexpr int DBUS_TIMEOUT_MS = 500;
GDBusConnection *connection = nullptr;
struct ControlDescription {
std::string name;
std::string role;
std::string value;
std::string states;
std::string description;
int position = 0;
int count = 0;
};
std::string to_utf8(const wchar_t *text) {
if (text == nullptr)
return {};
GError *error = nullptr;
gchar *converted = g_utf16_to_utf8(reinterpret_cast<const gunichar2 *>(text),
-1, nullptr, nullptr, &error);
if (error != nullptr)
g_error_free(error);
if (converted == nullptr)
return {};
std::string result{converted};
g_free(converted);
return result;
}
DWORD call_boolean(const char *method, GVariant *parameters) {
if (connection == nullptr)
return ERROR_SERVICE_NOT_ACTIVE;
GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync(
connection, BUS_NAME, OBJECT_PATH, INTERFACE_NAME, method, parameters,
G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error);
if (error != nullptr)
g_error_free(error);
if (reply == nullptr)
return ERROR_GEN_FAILURE;
gboolean result = FALSE;
g_variant_get(reply, "(b)", &result);
g_variant_unref(reply);
return result == TRUE ? ERROR_SUCCESS : ERROR_GEN_FAILURE;
}
bool cthulhu_is_available() {
GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync(
connection, "org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "NameHasOwner", g_variant_new("(s)", BUS_NAME),
G_VARIANT_TYPE("(b)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error);
if (error != nullptr)
g_error_free(error);
if (reply == nullptr)
return false;
gboolean owned = FALSE;
g_variant_get(reply, "(b)", &owned);
g_variant_unref(reply);
return owned == TRUE;
}
std::string variant_text(const VARIANT &value) {
if (value.vt == VT_BSTR)
return to_utf8(value.bstrVal);
if (value.vt == VT_I4) {
wchar_t buffer[128] = {};
if (GetRoleTextW(value.lVal, buffer, 128) > 0)
return to_utf8(buffer);
return std::to_string(value.lVal);
}
return {};
}
std::string accessible_property(
IAccessible *accessible,
HRESULT (STDMETHODCALLTYPE IAccessible::*getter)(VARIANT, BSTR *),
const VARIANT &child) {
BSTR value = nullptr;
const HRESULT result = (accessible->*getter)(child, &value);
if (FAILED(result) || value == nullptr)
return {};
const std::string text = to_utf8(value);
SysFreeString(value);
return text;
}
// Standard-control queries follow the LGPL wine-a11y reader published by
// Mudb0y, while presentation remains on Cthulhu's D-Bus interface.
void tidy_text(wchar_t *text, bool label = false) {
wchar_t *output = text;
for (wchar_t *input = text; *input != L'\0'; ++input) {
if (*input != L'&')
*output++ = *input;
}
*output = L'\0';
if (label && output > text && output[-1] == L':')
output[-1] = L'\0';
}
bool get_control_text(HWND window, wchar_t *text, int capacity) {
DWORD_PTR result = 0;
text[0] = L'\0';
if (!SendMessageTimeoutW(window, WM_GETTEXT, capacity,
reinterpret_cast<LPARAM>(text), SMTO_ABORTIFHUNG,
200, &result))
return false;
tidy_text(text);
return text[0] != L'\0';
}
void find_control_label(HWND window, wchar_t *text, int capacity) {
HWND previous = window;
wchar_t className[64] = {};
text[0] = L'\0';
for (int hop = 0; hop < 3; ++hop) {
previous = GetWindow(previous, GW_HWNDPREV);
if (previous == nullptr ||
!GetClassNameW(previous, className, std::size(className)))
return;
if (wcscmp(className, L"Static") != 0 && wcscmp(className, L"Button") != 0)
continue;
if (get_control_text(previous, text, capacity))
tidy_text(text, true);
return;
}
}
std::string window_title(HWND window) {
wchar_t title[512] = {};
GetWindowTextW(GetAncestor(window, GA_ROOT), title, std::size(title));
tidy_text(title);
return to_utf8(title);
}
std::string source_id(HWND window, const char *suffix) {
return std::to_string(reinterpret_cast<std::uintptr_t>(window)) + ":" +
suffix;
}
bool present_accessible_event(const std::string &sourceId,
const char *eventType,
const ControlDescription &control,
const std::string &windowTitle) {
return call_boolean("PresentAccessibleEvent",
g_variant_new(
"(sssssssiis)", sourceId.c_str(), eventType,
control.name.c_str(), control.role.c_str(),
control.value.c_str(), control.states.c_str(),
control.description.c_str(), control.position,
control.count, windowTitle.c_str())) == ERROR_SUCCESS;
}
ControlDescription describe_standard_control(HWND window) {
ControlDescription control;
wchar_t className[64] = {};
wchar_t text[512] = {};
wchar_t label[256] = {};
if (!GetClassNameW(window, className, std::size(className)))
return control;
get_control_text(window, text, std::size(text));
if (wcscmp(className, L"Button") == 0) {
control.name = to_utf8(text);
const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE);
DWORD_PTR checked = 0;
switch (style & BS_TYPEMASK) {
case BS_CHECKBOX:
case BS_AUTOCHECKBOX:
case BS_3STATE:
case BS_AUTO3STATE:
control.role = "check box";
SendMessageTimeoutW(window, BM_GETCHECK, 0, 0, SMTO_ABORTIFHUNG, 200,
&checked);
control.description = checked == BST_CHECKED ? "checked"
: checked == BST_INDETERMINATE ? "half checked"
: "not checked";
break;
case BS_RADIOBUTTON:
case BS_AUTORADIOBUTTON:
control.role = "radio button";
SendMessageTimeoutW(window, BM_GETCHECK, 0, 0, SMTO_ABORTIFHUNG, 200,
&checked);
control.description = checked == BST_CHECKED ? "checked" : "not checked";
break;
case BS_GROUPBOX:
control.role = "grouping";
break;
default:
control.role = "button";
}
return control;
}
const bool isEdit = wcscmp(className, L"Edit") == 0 ||
wcscmp(className, L"RICHEDIT50W") == 0 ||
wcscmp(className, L"RichEdit20W") == 0 ||
wcscmp(className, L"RichEdit20A") == 0;
if (isEdit) {
find_control_label(window, label, std::size(label));
control.name = to_utf8(label);
const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE);
control.role = style & ES_READONLY ? "read only edit" : "edit";
if (style & ES_PASSWORD)
control.description = "password";
else
control.value = to_utf8(text);
return control;
}
if (wcscmp(className, L"ComboBox") == 0 ||
wcscmp(className, L"ComboBoxEx32") == 0) {
find_control_label(window, label, std::size(label));
control.name = to_utf8(label);
control.role = "combo box";
control.value = to_utf8(text);
return control;
}
if (wcscmp(className, L"ListBox") == 0 ||
wcscmp(className, L"ComboLBox") == 0) {
find_control_label(window, label, std::size(label));
control.name = to_utf8(label);
control.role = "list";
DWORD_PTR selected = static_cast<DWORD_PTR>(-1);
DWORD_PTR count = 0;
DWORD_PTR length = 0;
SendMessageTimeoutW(window, LB_GETCOUNT, 0, 0, SMTO_ABORTIFHUNG, 200,
&count);
SendMessageTimeoutW(window, LB_GETCURSEL, 0, 0, SMTO_ABORTIFHUNG, 200,
&selected);
const LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE);
const bool stringItems =
!(style & (LBS_OWNERDRAWFIXED | LBS_OWNERDRAWVARIABLE)) ||
(style & LBS_HASSTRINGS);
if (static_cast<LONG_PTR>(selected) >= 0) {
SendMessageTimeoutW(window, LB_GETTEXTLEN, selected, 0, SMTO_ABORTIFHUNG,
200, &length);
if (stringItems && length < std::size(text) &&
SendMessageTimeoutW(window, LB_GETTEXT, selected,
reinterpret_cast<LPARAM>(text), SMTO_ABORTIFHUNG,
200, &length)) {
tidy_text(text);
control.value = to_utf8(text);
}
control.position = static_cast<int>(selected) + 1;
}
if (static_cast<LONG_PTR>(count) >= 0)
control.count = static_cast<int>(count);
return control;
}
if (wcscmp(className, L"msctls_trackbar32") == 0) {
find_control_label(window, label, std::size(label));
control.name = to_utf8(label);
control.role = "slider";
DWORD_PTR position = 0;
SendMessageTimeoutW(window, TBM_GETPOS, 0, 0, SMTO_ABORTIFHUNG, 200,
&position);
control.value = std::to_string(static_cast<LONG>(position));
return control;
}
if (wcscmp(className, L"Static") == 0) {
control.name = to_utf8(text);
control.role = "static text";
return control;
}
control.name = to_utf8(text);
return control;
}
bool present_standard_focus(HWND window) {
const ControlDescription control = describe_standard_control(window);
if (control.name.empty() && control.role.empty() && control.value.empty() &&
control.description.empty())
return false;
return present_accessible_event(source_id(window, "standard"), "focus",
control, window_title(window));
}
void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
LONG objectId, LONG childId, DWORD, DWORD) {
if (event != EVENT_OBJECT_FOCUS && event != EVENT_OBJECT_NAMECHANGE &&
event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION &&
event != EVENT_OBJECT_STATECHANGE)
return;
IAccessible *accessible = nullptr;
VARIANT child{};
VariantInit(&child);
if (FAILED(AccessibleObjectFromEvent(window, objectId, childId, &accessible,
&child)) ||
accessible == nullptr) {
if (event == EVENT_OBJECT_FOCUS)
present_standard_focus(window);
return;
}
const std::string name =
accessible_property(accessible, &IAccessible::get_accName, child);
const std::string value =
accessible_property(accessible, &IAccessible::get_accValue, child);
const std::string description =
accessible_property(accessible, &IAccessible::get_accDescription, child);
VARIANT role{};
VariantInit(&role);
std::string roleText;
if (SUCCEEDED(accessible->get_accRole(child, &role)))
roleText = variant_text(role);
VariantClear(&role);
VARIANT state{};
VariantInit(&state);
std::string stateText;
if (SUCCEEDED(accessible->get_accState(child, &state)))
stateText = variant_text(state);
VariantClear(&state);
LONG childCount = 0;
accessible->get_accChildCount(&childCount);
accessible->Release();
VariantClear(&child);
if (event == EVENT_OBJECT_FOCUS && name.empty() && value.empty() &&
description.empty() && (roleText.empty() || roleText == "client") &&
present_standard_focus(window))
return;
const std::string windowTitle = window_title(window);
const std::string sourceId =
std::to_string(reinterpret_cast<std::uintptr_t>(window)) + ":" +
std::to_string(objectId) + ":" + std::to_string(childId);
const char *eventType = nullptr;
switch (event) {
case EVENT_OBJECT_FOCUS:
eventType = "focus";
break;
case EVENT_OBJECT_NAMECHANGE:
eventType = "name";
break;
case EVENT_OBJECT_VALUECHANGE:
eventType = "value";
break;
case EVENT_OBJECT_SELECTION:
eventType = "selection";
break;
case EVENT_OBJECT_STATECHANGE:
eventType = "state";
break;
default:
return;
}
ControlDescription control;
control.name = name;
control.role = roleText;
control.value = value;
control.states = stateText;
control.description = description;
control.count = static_cast<int>(childCount);
present_accessible_event(sourceId, eventType, control, windowTitle);
}
void reset_rpc_server() {
RpcMgmtStopServerListening(nullptr);
RpcServerUnregisterIf(nullptr, nullptr, FALSE);
}
bool register_rpc_server() {
if (RpcServerUseProtseqEpW(
reinterpret_cast<RPC_WSTR>(const_cast<wchar_t *>(L"ncalrpc")),
RPC_C_PROTSEQ_MAX_REQS_DEFAULT,
reinterpret_cast<RPC_WSTR>(const_cast<wchar_t *>(L"NvdaCtlr")),
nullptr) != RPC_S_OK)
return false;
DWORD sessionId = 0;
if (!ProcessIdToSessionId(GetCurrentProcessId(), &sessionId)) {
reset_rpc_server();
return false;
}
wchar_t desktopName[256] = {};
DWORD bytesNeeded = 0;
if (!GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()),
UOI_NAME, desktopName, sizeof(desktopName),
&bytesNeeded)) {
reset_rpc_server();
return false;
}
wchar_t endpoint[512] = {};
wsprintfW(endpoint, L"NvdaCtlr.%lu.%s", sessionId, desktopName);
if (RpcServerUseProtseqEpW(
reinterpret_cast<RPC_WSTR>(const_cast<wchar_t *>(L"ncalrpc")),
RPC_C_PROTSEQ_MAX_REQS_DEFAULT, reinterpret_cast<RPC_WSTR>(endpoint),
nullptr) != RPC_S_OK) {
reset_rpc_server();
return false;
}
for (RPC_IF_HANDLE spec :
{NvdaController_v1_0_s_ifspec, NvdaController2_v1_0_s_ifspec,
NvdaController3_v1_0_s_ifspec}) {
if (RpcServerRegisterIf(spec, nullptr, nullptr) != RPC_S_OK) {
reset_rpc_server();
return false;
}
}
if (RpcServerListen(1, RPC_C_LISTEN_MAX_CALLS_DEFAULT, TRUE) == RPC_S_OK)
return true;
reset_rpc_server();
return false;
}
} // namespace
extern "C" void *__RPC_USER MIDL_user_allocate(size_t size) {
return malloc(size);
}
extern "C" void __RPC_USER MIDL_user_free(void *pointer) { free(pointer); }
extern "C" error_status_t __stdcall testIfRunning(void) {
return cthulhu_is_available() ? ERROR_SUCCESS : ERROR_SERVICE_NOT_ACTIVE;
}
extern "C" error_status_t __stdcall speakText(const wchar_t *text) {
const std::string utf8 = to_utf8(text);
// Tolk calls cancelSpeech separately when its interrupt argument is true.
return call_boolean("SpeakText", g_variant_new("(sb)", utf8.c_str(), FALSE));
}
extern "C" error_status_t __stdcall cancelSpeech(void) {
return call_boolean("CancelSpeech", nullptr);
}
extern "C" error_status_t __stdcall brailleMessage(const wchar_t *text) {
const std::string utf8 = to_utf8(text);
return call_boolean("BrailleMessage", g_variant_new("(s)", utf8.c_str()));
}
extern "C" error_status_t __stdcall getProcessId(ULONG *pid) {
if (pid == nullptr)
return ERROR_INVALID_PARAMETER;
*pid = GetCurrentProcessId();
return ERROR_SUCCESS;
}
extern "C" error_status_t __stdcall speakSsml(const wchar_t *ssml, SYMBOL_LEVEL,
SPEECH_PRIORITY, boolean) {
const std::string utf8 = to_utf8(ssml);
GError *error = nullptr;
GVariant *reply = g_dbus_connection_call_sync(
connection, BUS_NAME, OBJECT_PATH, INTERFACE_NAME, "SpeakSsml",
g_variant_new("(ss)", "wine-nvda-controller", utf8.c_str()),
G_VARIANT_TYPE("(i)"), G_DBUS_CALL_FLAGS_NONE, DBUS_TIMEOUT_MS, nullptr,
&error);
if (error != nullptr)
g_error_free(error);
if (reply == nullptr)
return ERROR_GEN_FAILURE;
gint result = ERROR_GEN_FAILURE;
g_variant_get(reply, "(i)", &result);
g_variant_unref(reply);
return static_cast<error_status_t>(result);
}
extern "C" error_status_t __stdcall onSsmlMarkReached(const wchar_t *) {
return ERROR_CALL_NOT_IMPLEMENTED;
}
extern "C" error_status_t __stdcall isSpeaking(boolean *speaking) {
if (speaking == nullptr)
return ERROR_INVALID_PARAMETER;
*speaking = FALSE;
return ERROR_CALL_NOT_IMPLEMENTED;
}
int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
const wchar_t *commandLine = GetCommandLineW();
const bool dialogReaderEnabled =
wcsstr(commandLine, L"--no-dialog-reader") == nullptr;
const bool controllerEnabled =
wcsstr(commandLine, L"--no-controller") == nullptr;
if (!dialogReaderEnabled && !controllerEnabled)
return 0;
HANDLE singleton =
CreateMutexW(nullptr, TRUE, L"Local\\cthulhu-wine-access-singleton");
if (singleton == nullptr)
return 1;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
CloseHandle(singleton);
return 0;
}
GError *error = nullptr;
connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
if (error != nullptr)
g_error_free(error);
if (connection == nullptr || !cthulhu_is_available()) {
CloseHandle(singleton);
return 1;
}
HWND window = nullptr;
bool controllerActive = false;
if (controllerEnabled &&
FindWindowW(L"wxWindowClassNR", L"NVDA") == nullptr) {
WNDCLASSW windowClass{};
windowClass.lpfnWndProc = DefWindowProcW;
windowClass.hInstance = instance;
windowClass.lpszClassName = L"wxWindowClassNR";
RegisterClassW(&windowClass);
window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0,
nullptr, nullptr, instance, nullptr);
if (window != nullptr) {
ShowWindow(window, SW_HIDE);
controllerActive = register_rpc_server();
}
}
if (!controllerActive && window != nullptr) {
DestroyWindow(window);
window = nullptr;
}
HWINEVENTHOOK hook = nullptr;
if (dialogReaderEnabled)
hook = SetWinEventHook(EVENT_MIN, EVENT_MAX, nullptr, winevent_callback, 0,
0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
if (hook != nullptr)
UnhookWinEvent(hook);
if (controllerActive) {
reset_rpc_server();
}
if (window != nullptr)
DestroyWindow(window);
g_object_unref(connection);
CloseHandle(singleton);
return 0;
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
typedef [v1_enum] enum tagSPEECH_PRIORITY {
SPEECH_PRIORITY_NORMAL = 0,
SPEECH_PRIORITY_NEXT = 1,
SPEECH_PRIORITY_NOW = 2
} SPEECH_PRIORITY;
typedef [v1_enum] enum tagSYMBOL_LEVEL {
SYMBOL_LEVEL_NONE = 0,
SYMBOL_LEVEL_SOME = 100,
SYMBOL_LEVEL_MOST = 200,
SYMBOL_LEVEL_ALL = 300,
SYMBOL_LEVEL_CHAR = 1000,
SYMBOL_LEVEL_UNCHANGED = -1
} SYMBOL_LEVEL;
[
uuid(DFF50B99-F7FD-4ca7-A82C-DAEB3E025295),
version(1.0)
]
interface NvdaController {
error_status_t __stdcall testIfRunning();
error_status_t __stdcall speakText([in, string] const wchar_t *text);
error_status_t __stdcall cancelSpeech();
error_status_t __stdcall brailleMessage([in, string] const wchar_t *message);
};
[
uuid(3D168D45-CB58-4270-8257-4E0BE515D557),
version(1.0)
]
interface NvdaController2 {
error_status_t __stdcall getProcessId([out] unsigned long *pid);
error_status_t __stdcall speakSsml(
[in, string] const wchar_t *ssml,
[in] const SYMBOL_LEVEL symbolLevel,
[in] const SPEECH_PRIORITY priority,
[in] const boolean asynchronous);
// WIDL lacks MIDL's callback attribute. Keeping this method in slot 2
// preserves the wire layout used by the unmodified controller client.
error_status_t __stdcall onSsmlMarkReached([in, string] const wchar_t *mark);
};
[
uuid(019e4216-a9a1-7540-a217-e1a2d2793025),
version(1.0)
]
interface NvdaController3 {
error_status_t __stdcall isSpeaking([out] boolean *speaking);
};
+14
View File
@@ -0,0 +1,14 @@
set(CMAKE_SYSTEM_NAME Linux)
find_program(WINEGCC winegcc REQUIRED)
find_program(WINEGXX wineg++ REQUIRED)
set(CMAKE_C_COMPILER "${WINEGCC}")
set(CMAKE_CXX_COMPILER "${WINEGXX}")
foreach(language C CXX)
set(CMAKE_SHARED_LIBRARY_PREFIX_${language} "")
set(CMAKE_SHARED_LIBRARY_SUFFIX_${language} ".dll")
endforeach()
set(CMAKE_SHARED_LIBRARY_PREFIX "")
set(CMAKE_SHARED_LIBRARY_SUFFIX ".dll")
set(CMAKE_SHARED_LIBRARY_SONAME_C_FLAG "")
set(CMAKE_SHARED_LIBRARY_SONAME_CXX_FLAG "")
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)