9 Commits

Author SHA1 Message Date
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
28 changed files with 1814 additions and 15 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']
+1 -1
View File
@@ -24,4 +24,4 @@
# Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.07.18"
codeName = "master"
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."""
+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')
+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()
@@ -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()
@@ -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"]',
@@ -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)