10 Commits

Author SHA1 Message Date
Storm Dragon ef7f74ed1b Merged changes from testing. 2026-07-19 15:01:58 -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
42 changed files with 1924 additions and 2116 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"
+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 = "testing"
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."""
+13 -163
View File
@@ -121,9 +121,6 @@ class InputEventManager:
self._wnck = None
self._did_attempt_wnck_load: bool = False
self._scriptWithSuspendedGrabsForXterm = None
self._xtermFocusScreen = None
self._xtermFocusHandlerId: int = 0
self._xtermRecoverySourceId: int = 0
def activate_device(self) -> Atspi.Device:
"""Creates and returns the AT-SPI device used by this manager."""
@@ -199,9 +196,6 @@ class InputEventManager:
self._key_pressed_id = 0
self._key_released_id = 0
self._device = None
self._stop_xterm_focus_monitor()
self._stop_xterm_recovery_timer()
self._scriptWithSuspendedGrabsForXterm = None
def get_debug_snapshot(self) -> Dict[str, object]:
"""Returns read-only counters useful for long-uptime diagnostics."""
@@ -321,27 +315,17 @@ class InputEventManager:
return []
grab_ids = []
try:
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
grab_ids.append(grab_id)
self._grabbed_bindings[grab_id] = binding
except Exception:
for grab_id in grab_ids:
try:
self._device.remove_key_grab(grab_id)
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not roll back key grab {grab_id}: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._grabbed_bindings.pop(grab_id, None)
raise
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
grab_ids.append(grab_id)
self._grabbed_bindings[grab_id] = binding
return grab_ids
@@ -535,7 +519,6 @@ class InputEventManager:
screen = wnck.Screen.get_default()
if screen is None:
return None
self._ensure_xterm_focus_monitor(screen)
screen.force_update()
return screen.get_active_window()
except Exception as error:
@@ -605,118 +588,10 @@ class InputEventManager:
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
def _stop_xterm_focus_monitor(self) -> None:
"""Stops monitoring X11 focus changes used for XTerm grab recovery."""
screen = self._xtermFocusScreen
handlerId = self._xtermFocusHandlerId
self._xtermFocusScreen = None
self._xtermFocusHandlerId = 0
if screen is None or not handlerId:
return
try:
screen.disconnect(handlerId)
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not stop XTerm focus monitor: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
def _ensure_xterm_focus_monitor(self, screen: Any) -> None:
"""Monitors X11 focus so suspended grabs recover without another key event."""
if screen is self._xtermFocusScreen and self._xtermFocusHandlerId:
return
self._stop_xterm_focus_monitor()
try:
handlerId = screen.connect(
"active-window-changed",
self._on_active_x11_window_changed,
)
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not start XTerm focus monitor: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self._xtermFocusScreen = screen
self._xtermFocusHandlerId = handlerId
def _stop_xterm_recovery_timer(self) -> None:
"""Stops the fallback timer used to recover XTerm-suspended grabs."""
sourceId = self._xtermRecoverySourceId
self._xtermRecoverySourceId = 0
if not sourceId:
return
try:
GLib.source_remove(sourceId)
except GLib.GError as error:
msg = f"INPUT EVENT MANAGER: Could not stop XTerm recovery timer: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
def _ensure_xterm_recovery_timer(self) -> None:
"""Starts fallback recovery when X11 focus notifications are insufficient."""
if self._device is None or self._xtermRecoverySourceId:
return
self._xtermRecoverySourceId = GLib.timeout_add(
100,
self._poll_xterm_grab_recovery,
)
def _poll_xterm_grab_recovery(self) -> bool:
"""Checks whether XTerm-suspended grabs can now be restored."""
if self._scriptWithSuspendedGrabsForXterm is None:
self._xtermRecoverySourceId = 0
return False
match = self._active_x11_window_xterm_match()
tokens = ["INPUT EVENT MANAGER: XTerm recovery matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is False:
self._xtermRecoverySourceId = 0
self._restore_key_grabs_after_xterm()
return False
keepPolling = not self._xtermFocusHandlerId or match is None
if not keepPolling:
self._xtermRecoverySourceId = 0
return keepPolling
def _on_active_x11_window_changed(self, screen: Any, _previousWindow: Any) -> None:
"""Restores suspended grabs as soon as X11 focus leaves XTerm."""
if self._scriptWithSuspendedGrabsForXterm is None:
return
try:
window = screen.get_active_window()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not check changed X11 focus: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
self._ensure_xterm_recovery_timer()
return
match = self._x11_window_xterm_match(window)
tokens = ["INPUT EVENT MANAGER: XTerm focus-change matcher returned", match]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
if match is False:
self._restore_key_grabs_after_xterm()
elif match is None:
self._ensure_xterm_recovery_timer()
def _active_x11_window_xterm_match(self) -> Optional[bool]:
"""Returns whether the active X11 window is XTerm, or None when unknown."""
window = self._get_active_x11_window()
return self._x11_window_xterm_match(window)
def _x11_window_xterm_match(self, window: Any) -> Optional[bool]:
"""Returns whether window is XTerm, or None when it cannot be identified."""
if window is None:
msg = "INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window."
debug.print_message(debug.LEVEL_INFO, msg, True)
@@ -941,13 +816,12 @@ class InputEventManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
removeGrabs()
self._scriptWithSuspendedGrabsForXterm = script
if not self._xtermFocusHandlerId:
self._ensure_xterm_recovery_timer()
def _restore_key_grabs_after_xterm(self) -> None:
"""Restores key grabs after leaving XTerm."""
script = self._scriptWithSuspendedGrabsForXterm
self._scriptWithSuspendedGrabsForXterm = None
if script is None:
return
@@ -959,8 +833,6 @@ class InputEventManager:
activeScript,
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
return
addGrabs = getattr(script, "addKeyGrabs", None)
@@ -972,29 +844,7 @@ class InputEventManager:
script,
]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
try:
addGrabs()
except Exception as error:
msg = f"INPUT EVENT MANAGER: Could not restore XTerm-suspended key grabs: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
removeGrabs = getattr(script, "removeKeyGrabs", None)
if callable(removeGrabs):
try:
removeGrabs()
except Exception as rollbackError:
msg = (
"INPUT EVENT MANAGER: Could not roll back partial XTerm grab "
f"restoration: {rollbackError}"
)
debug.print_message(debug.LEVEL_INFO, msg, True)
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
return
self._ensure_xterm_recovery_timer()
return
self._scriptWithSuspendedGrabsForXterm = None
self._stop_xterm_recovery_timer()
addGrabs()
# pylint: disable=too-many-arguments
# pylint: disable=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')
@@ -34,7 +34,6 @@ __license__ = "LGPL"
from cthulhu import debug
from cthulhu import cthulhu
from cthulhu import cthulhu_state
from cthulhu import keybindings
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
from cthulhu.scripts import default
@@ -50,7 +49,6 @@ class Script(web.Script):
super().__init__(app)
self.presentIfInactive = False
self._lastAutocompletePopupItem = None
def getBrailleGenerator(self):
"""Returns the braille generator for this script."""
@@ -327,9 +325,7 @@ class Script(web.Script):
if AXUtilities.is_frame(parent) and not AXObject.get_name(parent):
msg = "CHROMIUM: Event source believed to be in autocomplete popup"
debug.printMessage(debug.LEVEL_INFO, msg, True)
if event.source != self._lastAutocompletePopupItem:
self.presentObject(event.source, interrupt=True)
self._lastAutocompletePopupItem = event.source
cthulhu_state.locusOfFocus = event.source
return
msg = "CHROMIUM: Passing along event to default script"
@@ -420,22 +416,6 @@ class Script(web.Script):
if not self.utilities.canBeActiveWindow(event.source):
return
autocomplete = self.utilities.autocompletePopupForFrame(event.source)
if autocomplete:
msg = "CHROMIUM: Treating nameless listbox frame as autocomplete popup"
debug.printMessage(debug.LEVEL_INFO, msg, True)
selected = self.utilities.selectedChildren(autocomplete)
activeItem = selected[0] if len(selected) == 1 else None
if activeItem is None:
activeItem = AXUtilities.get_focused_object(autocomplete)
activeItem = activeItem or autocomplete
tokens = ["CHROMIUM: Presenting active autocomplete popup item", activeItem]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
self.presentObject(activeItem, interrupt=True)
self._lastAutocompletePopupItem = activeItem
return
# If this is a frame for a popup menu, we don't want to treat
# it like a proper window:activate event because it's not as
# far as the end-user experience is concerned.
@@ -480,30 +460,6 @@ class Script(web.Script):
def onWindowDeactivated(self, event):
"""Callback for window:deactivate accessibility events."""
if self.utilities.autocompletePopupForFrame(event.source):
msg = "CHROMIUM: Ignoring autocomplete popup deactivation"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._lastAutocompletePopupItem = None
return
focus = cthulhu_state.locusOfFocus
isAutocompleteCombo = AXUtilities.is_combo_box(focus) \
and AXUtilities.supports_autocompletion(focus)
if isAutocompleteCombo:
lastKey, mods = self.utilities.lastKeyAndModifiers()
if lastKey in ("Down", "Up") and mods & keybindings.ALT_MODIFIER_MASK:
msg = "CHROMIUM: Preserving state while opening autocomplete popup"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
app = AXObject.get_application(event.source)
for child in AXObject.iter_children(app):
popup = self.utilities.autocompletePopupForFrame(child)
if popup and AXUtilities.is_showing(child):
msg = "CHROMIUM: Preserving state during autocomplete popup activation"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
if super().onWindowDeactivated(event):
return
@@ -170,20 +170,6 @@ class Utilities(web.Utilities):
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return menu
def autocompletePopupForFrame(self, obj):
"""Returns the listbox in a Chromium autocomplete popup frame."""
if not AXUtilities.is_frame(obj) or AXObject.get_name(obj):
return None
# Native datalist/autofill popups are exposed as a direct listbox child
# of a separate nameless frame, without a popup-for relation.
if AXObject.get_child_count(obj) != 1:
return None
child = AXObject.get_child(obj, 0)
return child if AXUtilities.is_list_box(child) else None
def topLevelObject(self, obj, useFallbackSearch=False):
if not obj:
return None
+27 -130
View File
@@ -173,12 +173,8 @@ class Script(default.Script):
obj,
offset,
focusOverride=None,
isCaretSelection: bool | None = None,
) -> bool:
if isCaretSelection is None:
isCaretSelection = self.utilities.lastInputEventWasCaretNavWithSelection()
if not isCaretSelection:
):
if not self.utilities.lastInputEventWasCaretNavWithSelection():
return False
if focusOverride is not None:
@@ -2179,18 +2175,14 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
typingReasons = (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE)
if reason in typingReasons \
and event.source == cthulhu_state.locusOfFocus \
and AXUtilities.is_editable(event.source):
msg = f"WEB: Event handled: Updating position due to {reason}"
if self.utilities.textEventIsDueToInsertion(event):
msg = "WEB: Event handled: Updating position due to insertion"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._saveLastCursorPosition(event.source, event.detail1)
return True
deletionReasons = (TextEventReason.BACKSPACE, TextEventReason.DELETE)
if reason in deletionReasons and AXUtilities.is_editable(event.source):
msg = f"WEB: Event handled: Updating position due to {reason}"
if self.utilities.textEventIsDueToDeletion(event):
msg = "WEB: Event handled: Updating position due to deletion"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self._saveLastCursorPosition(event.source, event.detail1)
return True
@@ -2893,7 +2885,7 @@ class Script(default.Script):
self._currentTextAttrs = {}
return False
def onTextDeleted(self, event: Atspi.Event) -> bool:
def onTextDeleted(self, event):
"""Callback for object:text-changed:delete accessibility events."""
if self.utilities.isZombie(event.source):
@@ -2901,52 +2893,26 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
reason = AXUtilitiesEvent.get_text_event_reason(event)
if reason == TextEventReason.PAGE_SWITCH:
msg = f"WEB: Deletion is due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# UI and live-region classification precedes page-switch classification.
# Preserve page-switch ownership when those conditions overlap.
if self.utilities.lastInputEventWasPageSwitch():
msg = "WEB: Deletion is due to legacy page-switch fallback"
msg = "WEB: Deletion is believed to be due to page switch"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if reason == TextEventReason.LIVE_REGION_UPDATE:
msg = f"WEB: Ignoring deletion due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# Preserve web-specific live-region detection for values the shared
# classifier does not currently identify.
if self.utilities.isLiveRegion(event.source):
msg = "WEB: Ignoring deletion from legacy live-region fallback"
msg = "WEB: Ignoring deletion from live region"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if not self.utilities.inDocumentContent(event.source):
msg = "WEB: Event source is not in document content"
debug.printMessage(debug.LEVEL_INFO, msg, True)
if reason == TextEventReason.UI_UPDATE:
msg = f"WEB: Ignoring browser UI event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
return False
if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE:
msg = f"WEB: Ignoring event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsSpinnerNoise(event):
msg = "WEB: Ignoring: Event believed to be spinner noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -2961,26 +2927,13 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
self.utilities.clearContentCache()
deletionReasons = (TextEventReason.BACKSPACE, TextEventReason.DELETE)
if reason in deletionReasons \
and AXUtilities.is_editable(event.source) \
and (reason == TextEventReason.DELETE or not self.utilities.isHidden(event.source)):
msg = f"WEB: Event is due to editable text deletion: {reason}"
if self.utilities.textEventIsDueToDeletion(event):
msg = "WEB: Event believed to be due to editable text deletion"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
inputEvent = cthulhu_state.lastNonModifierKeyEvent
unmodifiedPrintableInput = \
isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent) \
and inputEvent \
and inputEvent.is_printable_key() \
and not inputEvent.modifiers
typingReasons = (TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE)
if reason in typingReasons \
and event.source == cthulhu_state.locusOfFocus \
and AXUtilities.is_editable(event.source) \
and unmodifiedPrintableInput:
msg = f"WEB: Ignoring insertion-caused deletion: {reason}"
if self.utilities.textEventIsDueToInsertion(event):
msg = "WEB: Ignoring event believed to be due to text insertion"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
@@ -3033,33 +2986,13 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
reason = AXUtilitiesEvent.get_text_event_reason(event)
if reason == TextEventReason.PAGE_SWITCH:
msg = f"WEB: Insertion is due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# UI and live-region classification precedes page-switch classification.
# Preserve page-switch ownership when those conditions overlap.
if self.utilities.lastInputEventWasPageSwitch():
msg = "WEB: Insertion is due to legacy page-switch fallback"
msg = "WEB: Insertion is believed to be due to page switch"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if reason == TextEventReason.LIVE_REGION_UPDATE:
if self.utilities.handleAsLiveRegion(event):
msg = f"WEB: Event handled as {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self.liveRegionManager.handleEvent(event)
else:
msg = f"WEB: Ignoring unpresented {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# Preserve web-specific live-region detection for values the shared
# classifier does not currently identify, such as container-live=off.
if self.utilities.handleAsLiveRegion(event):
msg = "WEB: Event handled by legacy live-region fallback"
msg = "WEB: Event to be handled as live region"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self.liveRegionManager.handleEvent(event)
return True
@@ -3069,39 +3002,21 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if reason == TextEventReason.CHILDREN_CHANGE:
msg = f"WEB: Ignoring event due to {reason}"
if self.utilities.eventIsEOCAdded(event):
msg = "WEB: Ignoring: Event was for embedded object char"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
# Preserve the broader legacy match for whitespace plus embedded
# object characters until the shared classifier owns that pattern.
if self.utilities.eventIsEOCAdded(event):
msg = "WEB: Ignoring: Event was for embedded object char"
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if not self.utilities.inDocumentContent(event.source):
msg = "WEB: Event source is not in document content"
debug.printMessage(debug.LEVEL_INFO, msg, True)
if reason == TextEventReason.UI_UPDATE:
msg = f"WEB: Ignoring browser UI event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsBrowserUINoise(event):
msg = "WEB: Ignoring event believed to be browser UI noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
return False
if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE:
msg = f"WEB: Ignoring event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.eventIsSpinnerNoise(event):
msg = "WEB: Ignoring: Event believed to be spinner noise"
debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -3112,11 +3027,6 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if reason == TextEventReason.AUTO_INSERTION_PRESENTABLE:
msg = f"WEB: Deferring presentable event due to {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
msg = "WEB: Clearing content cache due to text insertion"
debug.printMessage(debug.LEVEL_INFO, msg, True)
self.utilities.clearContentCache()
@@ -3185,8 +3095,6 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
reason = AXUtilitiesEvent.get_text_event_reason(event)
if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus):
tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus,
"is not in document content"]
@@ -3214,18 +3122,8 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
selectionReasons = (
TextEventReason.SELECTION_BY_CHARACTER,
TextEventReason.SELECTION_BY_LINE,
TextEventReason.SELECTION_BY_PARAGRAPH,
TextEventReason.SELECTION_BY_PAGE,
TextEventReason.SELECTION_BY_WORD,
TextEventReason.SELECTION_TO_FILE_BOUNDARY,
TextEventReason.SELECTION_TO_LINE_BOUNDARY,
TextEventReason.UNSPECIFIED_SELECTION,
)
isSelectionReason = reason in selectionReasons
if isSelectionReason and not AXText.get_selected_ranges(event.source):
if self.utilities.lastInputEventWasCaretNavWithSelection() \
and not AXText.get_selected_ranges(event.source):
document = self.utilities.getTopLevelDocumentForObject(event.source)
obj, offset = self.utilities.getCaretContext(document, False, False)
focusOffset = AXText.get_caret_offset(event.source)
@@ -3235,7 +3133,6 @@ class Script(default.Script):
obj,
offset,
focusOverride=(event.source, focusOffset),
isCaretSelection=True,
):
msg = "WEB: Event handled: synthetic selection from event source"
debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -3256,7 +3153,7 @@ class Script(default.Script):
offset = AXText.get_caret_offset(event.source)
char = AXText.get_substring(event.source, offset, offset + 1)
if char == self.EMBEDDED_OBJECT_CHARACTER \
and not isSelectionReason \
and not self.utilities.lastInputEventWasCaretNavWithSelection() \
and not self.utilities.lastInputEventWasCommand():
msg = "WEB: Ignoring: Not selecting and event offset is at embedded object"
debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -40,6 +40,7 @@ import time
import urllib
from cthulhu import debug
from cthulhu import input_event
from cthulhu import input_event_manager
from cthulhu import messages
from cthulhu import cthulhu
@@ -4681,6 +4682,32 @@ class Utilities(script_utilities.Utilities):
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
def textEventIsDueToDeletion(self, event):
if not self.inDocumentContent(event.source) \
or not AXUtilities.is_editable(event.source):
return False
if self.isDeleteCommandTextDeletionEvent(event) \
or self.isBackSpaceCommandTextDeletionEvent(event):
return True
return False
def textEventIsDueToInsertion(self, event):
if not event.type.startswith("object:text-"):
return False
if not self.inDocumentContent(event.source) \
or event.source != cthulhu_state.locusOfFocus \
or not AXUtilities.is_editable(event.source):
return False
if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent):
inputEvent = cthulhu_state.lastNonModifierKeyEvent
return inputEvent and inputEvent.is_printable_key() and not inputEvent.modifiers
return False
def textEventIsForNonNavigableTextObject(self, event):
if not event.type.startswith("object:text-"):
return False
+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()
-183
View File
@@ -8,9 +8,6 @@ from gi.repository import Atspi
from cthulhu import ax_object
from cthulhu import cthulhu_state
from cthulhu import focus_manager
from cthulhu.scripts import default
from cthulhu.scripts.toolkits.Chromium import script as chromium_script
from cthulhu.scripts.toolkits.Chromium import script_utilities as chromium_utilities
class ChromiumOmniboxRegressionTests(unittest.TestCase):
@@ -66,186 +63,6 @@ class ChromiumOmniboxRegressionTests(unittest.TestCase):
self.assertIs(manager.get_active_window(), activeWindow)
self.assertIs(cthulhu_state.activeWindow, activeWindow)
def test_detects_nameless_frame_with_listbox_as_autocomplete_popup(self):
utilities = chromium_utilities.Utilities.__new__(chromium_utilities.Utilities)
frame = object()
listbox = object()
with (
mock.patch.object(chromium_utilities.AXUtilities, "is_frame", return_value=True),
mock.patch.object(chromium_utilities.AXObject, "get_name", return_value=""),
mock.patch.object(chromium_utilities.AXObject, "get_child_count", return_value=1),
mock.patch.object(chromium_utilities.AXObject, "get_child", return_value=listbox),
mock.patch.object(chromium_utilities.AXUtilities, "is_list_box", return_value=True),
):
result = utilities.autocompletePopupForFrame(frame)
self.assertIs(result, listbox)
def test_does_not_treat_nested_listbox_as_autocomplete_popup(self):
utilities = chromium_utilities.Utilities.__new__(chromium_utilities.Utilities)
frame = object()
with (
mock.patch.object(chromium_utilities.AXUtilities, "is_frame", return_value=True),
mock.patch.object(chromium_utilities.AXObject, "get_name", return_value=""),
mock.patch.object(chromium_utilities.AXObject, "get_child_count", return_value=2),
):
result = utilities.autocompletePopupForFrame(frame)
self.assertIsNone(result)
@staticmethod
def _make_chromium_script():
testScript = chromium_script.Script.__new__(chromium_script.Script)
testScript.utilities = mock.Mock()
testScript.utilities.canBeActiveWindow.return_value = True
testScript.utilities.autocompletePopupForFrame.return_value = None
testScript.utilities.popupMenuForFrame.return_value = None
testScript._lastAutocompletePopupItem = None
testScript.presentObject = mock.Mock()
return testScript
def test_autocomplete_popup_activation_presents_item_without_frame_fallback(self):
testScript = self._make_chromium_script()
frame = object()
listbox = object()
item = object()
event = mock.Mock(source=frame)
testScript.utilities.autocompletePopupForFrame.return_value = listbox
testScript.utilities.selectedChildren.return_value = [item]
with (
mock.patch.object(chromium_script.AXUtilities, "get_focused_object", return_value=item),
mock.patch.object(chromium_script.cthulhu, "setActiveWindow") as setActiveWindow,
mock.patch.object(chromium_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
mock.patch.object(default.Script, "onWindowActivated") as defaultHandler,
):
chromium_script.Script.onWindowActivated(testScript, event)
setActiveWindow.assert_not_called()
setLocusOfFocus.assert_not_called()
testScript.presentObject.assert_called_once_with(item, interrupt=True)
self.assertIs(testScript._lastAutocompletePopupItem, item)
defaultHandler.assert_not_called()
def test_autocomplete_popup_activation_uses_focused_item_when_none_selected(self):
testScript = self._make_chromium_script()
frame = object()
listbox = object()
item = object()
event = mock.Mock(source=frame)
testScript.utilities.autocompletePopupForFrame.return_value = listbox
testScript.utilities.selectedChildren.return_value = []
with mock.patch.object(
chromium_script.AXUtilities,
"get_focused_object",
return_value=item,
):
chromium_script.Script.onWindowActivated(testScript, event)
testScript.presentObject.assert_called_once_with(item, interrupt=True)
self.assertIs(testScript._lastAutocompletePopupItem, item)
def test_autocomplete_popup_selection_is_presented_without_changing_document_focus(self):
testScript = self._make_chromium_script()
comboBox = object()
listbox = object()
frame = object()
item = object()
event = mock.Mock(source=item, detail1=True)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.inDocumentContent.return_value = False
with (
mock.patch.object(chromium_script.web.Script, "onSelectedChanged", return_value=False),
mock.patch.object(
chromium_script.AXObject,
"find_ancestor_inclusive",
return_value=listbox,
),
mock.patch.object(chromium_script.AXObject, "get_parent", return_value=frame),
mock.patch.object(chromium_script.AXObject, "get_name", return_value=""),
mock.patch.object(chromium_script.AXUtilities, "is_frame", return_value=True),
):
chromium_script.Script.onSelectedChanged(testScript, event)
chromium_script.Script.onSelectedChanged(testScript, event)
self.assertIs(cthulhu_state.locusOfFocus, comboBox)
testScript.presentObject.assert_called_once_with(item, interrupt=True)
def test_main_window_deactivation_for_autocomplete_popup_preserves_state(self):
testScript = self._make_chromium_script()
mainFrame = object()
comboBox = object()
event = mock.Mock(source=mainFrame)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.lastKeyAndModifiers.return_value = ("Down", 8)
with (
mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True),
mock.patch.object(
chromium_script.AXUtilities,
"supports_autocompletion",
return_value=True,
),
mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler,
):
chromium_script.Script.onWindowDeactivated(testScript, event)
defaultHandler.assert_not_called()
def test_unrelated_autocomplete_window_deactivation_reaches_default(self):
testScript = self._make_chromium_script()
mainFrame = object()
comboBox = object()
app = object()
event = mock.Mock(source=mainFrame)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.lastKeyAndModifiers.return_value = ("w", 4)
with (
mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True),
mock.patch.object(
chromium_script.AXUtilities,
"supports_autocompletion",
return_value=True,
),
mock.patch.object(chromium_script.AXObject, "get_application", return_value=app),
mock.patch.object(chromium_script.AXObject, "iter_children", return_value=iter(())),
mock.patch.object(chromium_script.web.Script, "onWindowDeactivated", return_value=False),
mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler,
):
chromium_script.Script.onWindowDeactivated(testScript, event)
defaultHandler.assert_called_once_with(testScript, event)
def test_unmodified_arrow_deactivation_reaches_default(self):
testScript = self._make_chromium_script()
mainFrame = object()
comboBox = object()
app = object()
event = mock.Mock(source=mainFrame)
cthulhu_state.locusOfFocus = comboBox
testScript.utilities.lastKeyAndModifiers.return_value = ("Down", 0)
with (
mock.patch.object(chromium_script.AXUtilities, "is_combo_box", return_value=True),
mock.patch.object(
chromium_script.AXUtilities,
"supports_autocompletion",
return_value=True,
),
mock.patch.object(chromium_script.AXObject, "get_application", return_value=app),
mock.patch.object(chromium_script.AXObject, "iter_children", return_value=iter(())),
mock.patch.object(chromium_script.web.Script, "onWindowDeactivated", return_value=False),
mock.patch.object(default.Script, "onWindowDeactivated") as defaultHandler,
):
chromium_script.Script.onWindowDeactivated(testScript, event)
defaultHandler.assert_called_once_with(testScript, event)
if __name__ == "__main__":
unittest.main()
@@ -7,33 +7,13 @@ 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:
@@ -333,38 +313,23 @@ 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(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),
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,
):
manager = event_manager.EventManager(mock.Mock())
manager = cthulhu.event_manager.EventManager(mock.Mock())
manager.set_compositor_state_adapter(adapter)
manager._sync_focus_on_startup()
fakeCthulhu.setActiveWindow.assert_called_once_with(
window,
alsoSetLocusOfFocus=True,
notifyScript=False,
)
fakeCthulhu.setLocusOfFocus.assert_called_once_with(
None,
focusedObject,
notifyScript=True,
force=True,
)
setActiveWindow.assert_called_once_with(window, alsoSetLocusOfFocus=True, notifyScript=False)
setLocusOfFocus.assert_called_once_with(None, focusedObject, notifyScript=True, force=True)
adapter.sync_accessible_context.assert_called_once_with("event-manager-startup")
@@ -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()
+3 -9
View File
@@ -30,7 +30,6 @@ sys.modules.setdefault(
),
)
from cthulhu import ax_document_selection
from cthulhu.script_utilities import Utilities
@@ -132,14 +131,9 @@ 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))
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.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))
stack.enter_context(
mock.patch(
"cthulhu.script_utilities.cthulhu.cthulhuApp",
@@ -34,12 +34,11 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
self.addCleanup(self._restore_cthulhu_state)
self.listener = mock.Mock()
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.EventListener,
"new",
return_value=self.listener,
)
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop)
@@ -27,12 +27,11 @@ class FakeEvent:
class EventManagerRelevanceGateRegressionTests(unittest.TestCase):
def setUp(self) -> None:
self.listener = mock.Mock()
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.EventListener,
"new",
return_value=self.listener,
)
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop)
@@ -306,25 +306,5 @@ class InputEventManagerKeyWatcherTests(unittest.TestCase):
[(modified, None), (unrelated, None)],
)
def test_keybinding_grab_failure_rolls_back_partial_additions(self) -> None:
manager = input_event_manager.InputEventManager()
device = mock.Mock()
device.add_key_grab.side_effect = [101, RuntimeError("grab failed")]
manager._device = device
firstDefinition = types.SimpleNamespace(keycode=44, modifiers=0)
secondDefinition = types.SimpleNamespace(keycode=45, 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 = 44
binding.key_definitions.return_value = [firstDefinition, secondDefinition]
with self.assertRaisesRegex(RuntimeError, "grab failed"):
manager.add_grabs_for_keybinding(binding)
device.remove_key_grab.assert_called_once_with(101)
self.assertEqual(manager._grabbed_bindings, {})
if __name__ == "__main__":
unittest.main()
@@ -525,114 +525,6 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
oldScript.removeKeyGrabs.assert_called_once_with()
oldScript.addKeyGrabs.assert_not_called()
def test_xterm_grabs_restore_on_x11_focus_change_without_keyboard_event(self):
manager = input_event_manager.InputEventManager()
activeScript = mock.Mock()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = activeScript
screen = mock.Mock()
activeWindow = object()
screen.get_active_window.return_value = activeWindow
manager._scriptWithSuspendedGrabsForXterm = activeScript
with (
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(manager, "_x11_window_xterm_match", return_value=False) as matcher,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._on_active_x11_window_changed(screen, None)
matcher.assert_called_once_with(activeWindow)
activeScript.addKeyGrabs.assert_called_once_with()
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
def test_xterm_focus_check_failure_starts_recovery_timer(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
manager._scriptWithSuspendedGrabsForXterm = object()
screen = mock.Mock()
screen.get_active_window.side_effect = RuntimeError("focus unavailable")
with (
mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23),
mock.patch.object(input_event_manager.debug, "print_message"),
):
manager._on_active_x11_window_changed(screen, None)
self.assertEqual(manager._xtermRecoverySourceId, 23)
def test_xterm_focus_monitor_is_connected_only_once(self):
manager = input_event_manager.InputEventManager()
screen = mock.Mock()
screen.connect.return_value = 17
manager._ensure_xterm_focus_monitor(screen)
manager._ensure_xterm_focus_monitor(screen)
screen.connect.assert_called_once_with(
"active-window-changed",
manager._on_active_x11_window_changed,
)
def test_xterm_suspension_starts_fallback_when_focus_monitor_is_unavailable(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
activeScript = mock.Mock()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = activeScript
with (
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23) as timeoutAdd,
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._suspend_key_grabs_for_xterm()
timeoutAdd.assert_called_once_with(100, manager._poll_xterm_grab_recovery)
self.assertEqual(manager._xtermRecoverySourceId, 23)
def test_xterm_grab_restore_failure_keeps_retry_state(self):
manager = input_event_manager.InputEventManager()
manager._device = object()
activeScript = mock.Mock()
activeScript.addKeyGrabs.side_effect = RuntimeError("grab failed")
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = activeScript
manager._scriptWithSuspendedGrabsForXterm = activeScript
with (
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
mock.patch.object(input_event_manager.GLib, "timeout_add", return_value=23),
mock.patch.object(input_event_manager.debug, "print_message"),
mock.patch.object(input_event_manager.debug, "print_tokens"),
):
manager._restore_key_grabs_after_xterm()
self.assertIs(manager._scriptWithSuspendedGrabsForXterm, activeScript)
self.assertEqual(manager._xtermRecoverySourceId, 23)
activeScript.removeKeyGrabs.assert_called_once_with()
def test_stopping_key_watcher_disconnects_xterm_focus_monitor(self):
manager = input_event_manager.InputEventManager()
screen = mock.Mock()
manager._xtermFocusScreen = screen
manager._xtermFocusHandlerId = 17
manager._xtermRecoverySourceId = 23
manager._scriptWithSuspendedGrabsForXterm = object()
with (
mock.patch.object(input_event_manager.GLib, "source_remove") as sourceRemove,
mock.patch.object(input_event_manager.debug, "print_message"),
):
manager.stop_key_watcher()
screen.disconnect.assert_called_once_with(17)
sourceRemove.assert_called_once_with(23)
self.assertIsNone(manager._xtermFocusScreen)
self.assertEqual(manager._xtermFocusHandlerId, 0)
self.assertEqual(manager._xtermRecoverySourceId, 0)
self.assertIsNone(manager._scriptWithSuspendedGrabsForXterm)
def test_identifier_is_xterm_matches_exact_xterm_only(self):
self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("xterm"))
self.assertTrue(input_event_manager.InputEventManager._identifier_is_xterm("/usr/bin/xterm"))
@@ -2,7 +2,6 @@ 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"))
@@ -134,20 +133,6 @@ 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()
@@ -161,10 +146,16 @@ 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", fakeAtspi),
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.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review, "Wnck", None),
):
@@ -179,10 +170,16 @@ 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", fakeAtspi),
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.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review, "Wnck", None),
@@ -205,19 +202,14 @@ 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", fakeAtspi),
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.input_event_manager, "get_manager"),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review, "Gdk", fakeGdk),
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, "Wnck", fakeWnck),
):
reviewer = mouse_review.MouseReviewer(self._make_app(enabled=False))
@@ -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
@@ -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)