Add native Wine accessibility bridge

This commit is contained in:
Storm Dragon
2026-07-17 18:17:23 -04:00
parent 0576b6f79f
commit c9f56cb7ed
24 changed files with 1188 additions and 8 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**: Focus, name, value, role, state, and selection changes from MSAA controls
- **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,12 +1,13 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu-git
pkgbase=cthulhu-git
pkgname=(cthulhu-git cthulhu-wine-access-git)
_pkgname=cthulhu
pkgver=2026.05.25.r407.gc39f231
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)
provides=("${_pkgname}")
conflicts=("${_pkgname}")
@@ -77,6 +78,9 @@ optdepends=(
# Window Title Reader plugin (optional)
'python-xlib: X11 access for Wine window title plugin'
)
optdepends_x86_64=(
'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration'
)
makedepends=(
git
meson
@@ -85,6 +89,10 @@ makedepends=(
python-installer
python-wheel
)
makedepends_x86_64=(
cmake
wine
)
install=cthulhu.install
source=(
"${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master"
@@ -115,12 +123,31 @@ build() {
meson compile -C _build
}
package() {
package_cthulhu-git() {
arch=(any)
provides=(cthulhu)
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:
+30 -3
View File
@@ -1,11 +1,12 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu
pkgbase=cthulhu
pkgname=(cthulhu cthulhu-wine-access)
pkgver=2026.05.25
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)
depends=(
# Core AT-SPI accessibility
@@ -74,6 +75,9 @@ optdepends=(
# Window Title Reader plugin (optional)
'python-xlib: X11 access for Wine window title plugin'
)
optdepends_x86_64=(
'cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration'
)
makedepends=(
git
meson
@@ -82,6 +86,10 @@ makedepends=(
python-installer
python-wheel
)
makedepends_x86_64=(
cmake
wine
)
install=cthulhu.install
source=(
"git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}"
@@ -98,12 +106,31 @@ build() {
meson compile -C _build
}
package() {
package_cthulhu() {
arch=(any)
provides=(cthulhu)
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.
+45
View File
@@ -0,0 +1,45 @@
# 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 exits and yields
to it.
## 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. Therefore applications can keep the original
`nvdaControllerClient32.dll`, `nvdaControllerClient64.dll`, and `Tolk.dll`; no DLL override is
required.
Plain speech, braille, cancel, and process ID calls are 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. This makes
ordinary Win32 dialogs and controls useful. It does not expose custom-drawn, DirectX, or
game-specific interfaces. UI Automation coverage and an AT-SPI object broker remain separate later
stages; the current helper presents normalized events directly through Cthulhu.
## 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.
+48
View File
@@ -65,6 +65,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"
+76
View File
@@ -678,6 +678,82 @@ 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 sourceId, eventType, states
parts = [part for part in (name, role, value, description) if part]
if position > 0 and count > 0:
parts.append(f"{position} of {count}")
if windowTitle and windowTitle not in parts:
parts.append(windowTitle)
if not parts:
return False
return self.PresentMessage(", ".join(parts))
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"
):
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,45 @@ 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_called_once_with("Open, button, 2 of 4, Settings")
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}")
+282
View File
@@ -0,0 +1,282 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "nvdaController.h"
#include <gio/gio.h>
#include <oleacc.h>
#include <rpc.h>
#include <windows.h>
#include <cstdlib>
#include <cwchar>
#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;
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;
}
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)
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);
wchar_t title[512] = {};
GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512);
const std::string windowTitle = to_utf8(title);
const char *eventType = event == EVENT_OBJECT_FOCUS ? "focus" : "change";
call_boolean("PresentAccessibleEvent",
g_variant_new("(sssssssiis)", "msaa", eventType, name.c_str(),
roleText.c_str(), value.c_str(), stateText.c_str(),
description.c_str(), 0,
static_cast<int>(childCount),
windowTitle.c_str()));
}
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))
return false;
wchar_t desktopName[256] = {};
DWORD bytesNeeded = 0;
if (!GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()),
UOI_NAME, desktopName, sizeof(desktopName),
&bytesNeeded))
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)
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)
return false;
}
return RpcServerListen(1, RPC_C_LISTEN_MAX_CALLS_DEFAULT, TRUE) == RPC_S_OK;
}
} // 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);
return call_boolean("SpeakText", g_variant_new("(sb)", utf8.c_str(), TRUE));
}
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;
if (controllerEnabled && FindWindowW(L"wxWindowClassNR", L"NVDA") != nullptr)
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())
return 1;
HWND window = nullptr;
if (controllerEnabled) {
WNDCLASSW windowClass{};
windowClass.lpfnWndProc = DefWindowProcW;
windowClass.hInstance = instance;
windowClass.lpszClassName = L"wxWindowClassNR";
RegisterClassW(&windowClass);
window = CreateWindowW(windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0,
nullptr, nullptr, instance, nullptr);
}
if ((controllerEnabled && (window == nullptr || !register_rpc_server()))) {
g_object_unref(connection);
return 1;
}
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 (controllerEnabled) {
RpcMgmtStopServerListening(nullptr);
RpcServerUnregisterIf(nullptr, nullptr, FALSE);
DestroyWindow(window);
}
g_object_unref(connection);
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)