Make d-bus available to orca if someone decides they want to implement it.

This commit is contained in:
Storm Dragon committed 2026-10-12 04:37:12 -04:00
1 parent c3e981b4c2
commit 0f59809e9a
11 files changed
+374 -39

No files matched your search

+31
View File
@@ -1,5 +1,36 @@
# Wine control integration
## Reader endpoint selection
After building, run these checks sequentially on private displays:
```sh
xvfb-run -a python3 tests/controller_integration.py --build-dir build
xvfb-run -a python3 tests/controller_integration.py --build-dir build --reader orca
xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca
xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca --fixture edit
xvfb-run -a python3 tests/review_integration.py --build-dir build --reader orca
```
The controller test drives the helper through a native Windows NVDA RPC client.
It verifies speech, cancellation, braille, SSML status forwarding, reader loss,
invalid reader values, and startup refusal when only the unselected service
exists. Both service names are owned while the selected reader handles requests.
The menu/editor and review tests also accept `--reader cthulhu`; omit the option
to check existing default behavior. Review checks reject callers even when they
own the unselected service, then replace the selected service owner and verify
registration and capture access move to the new owner. Its provider object path
and interface remain the same for both readers.
These tests supply recording services implementing the Wine protocol; they do
not run Orca or prove audible speech or physical braille. Stock Orca still needs
the reader-side methods and helper lifecycle described in the
[integration contract](../README.md#reader-selection-and-orca-integration).
Before submitting, check Cthulhu's ordinary Wine menus, editor arrows, repeated
characters, selections, deletion, protected fields and keypad review with live
speech and braille. When an Orca integration exists, repeat those workflows
with `--reader orca`, including return to Linux controls and reader restart.
## Virtual desktop focus recovery
`xvfb-run -a ctest --test-dir build -R foreground-focus --output-on-failure`
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <rpc.h>
// The test client IDL supplies an explicit local binding; its wire interfaces
// and method signatures are otherwise identical to the helper's IDL.
#include "nvdaController_c.c"
void *__RPC_USER MIDL_user_allocate(size_t size) { return malloc(size); }
void __RPC_USER MIDL_user_free(void *pointer) { free(pointer); }
static int check_status(const char *operation, error_status_t status) {
if (status == ERROR_SUCCESS) return 0;
fprintf(stderr, "%s failed: %lu\n", operation, (unsigned long)status);
return 1;
}
int main(int argc, char **argv) {
RPC_WSTR bindingText = NULL;
RPC_BINDING_HANDLE binding = NULL;
if (RpcStringBindingComposeW(NULL, (RPC_WSTR)L"ncalrpc", NULL,
(RPC_WSTR)L"NvdaCtlr", NULL, &bindingText) ||
RpcBindingFromStringBindingW(bindingText, &binding))
return 1;
RpcStringFreeW(&bindingText);
controllerBinding = binding;
ULONGLONG deadline = GetTickCount64() + 5000;
while (RpcMgmtIsServerListening(binding) != RPC_S_OK) {
if (GetTickCount64() >= deadline) {
fprintf(stderr, "controller RPC server did not become ready\n");
RpcBindingFree(&binding);
return 1;
}
Sleep(10);
}
int failed = 0;
if (argc > 1 && strcmp(argv[1], "unavailable") == 0) {
failed = testIfRunning() != ERROR_SERVICE_NOT_ACTIVE;
} else {
failed |= check_status("testIfRunning", testIfRunning());
failed |= check_status("speakText", speakText(L"Controller speech"));
failed |= check_status("cancelSpeech", cancelSpeech());
failed |= check_status("brailleMessage", brailleMessage(L"Controller braille"));
// The reader's SSML status must reach the caller without conversion.
failed |= speakSsml(L"<speak>Controller SSML</speak>", SYMBOL_LEVEL_ALL,
SPEECH_PRIORITY_NORMAL, FALSE) != ERROR_CALL_NOT_IMPLEMENTED;
}
RpcBindingFree(&binding);
return failed;
}
+119
View File
@@ -0,0 +1,119 @@
"""Check NVDA RPC routing against private reader D-Bus services."""
import argparse
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from gi.repository import Gio, GLib
parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build")
parser.add_argument("--reader", choices=("cthulhu", "orca"))
parser.add_argument("--session", action="store_true")
args = parser.parse_args()
build = Path(args.build_dir).resolve()
if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__,
"--session", "--build-dir", str(build),
*(["--reader", args.reader] if args.reader else [])]))
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
otherInterface = "org.stormux.Cthulhu1.Service" if args.reader == "orca" else "org.gnome.Orca1.Service"
objectPath = "/" + interface.replace(".", "/")
methods = {"SpeakText": ("sb", "b"), "CancelSpeech": ("", "b"),
"BrailleMessage": ("s", "b"), "SpeakSsml": ("ss", "i")}
xml = '<node><interface name="' + interface + '">'
for method, (inputs, output) in methods.items():
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in inputs)
xml += '<arg type="' + output + '" direction="out"/></method>'
xml += '</interface></node>'
events = []
def record(connection, sender, path, iface, method, parameters, invocation):
events.append((method, parameters.unpack()))
invocation.return_value(GLib.Variant("(i)", (120,)) if method == "SpeakSsml"
else GLib.Variant("(b)", (True,)))
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
def name_call(method, name):
parameters = GLib.Variant("(su)", (name, 0)) if method == "RequestName" else GLib.Variant("(s)", (name,))
return connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", method, parameters, None,
Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
def wait_for_client(client):
deadline = time.monotonic() + 10
while client.poll() is None and time.monotonic() < deadline:
while GLib.MainContext.default().iteration(False):
pass
time.sleep(.005)
assert client.poll() == 0, f"controller client failed or timed out: {client.poll()}"
with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
tempPath = Path(temporary)
env = os.environ.copy()
env.update(WINEPREFIX=str(tempPath / "prefix"), WINEDEBUG="-all",
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
source = Path(__file__).resolve().parent.parent
# Like NVDA's clients, bind directly to the local endpoint. WIDL's default
# automatic binding is unsupported by Wine's client NDR interpreter.
clientIdl = tempPath / "nvdaController.idl"
clientIdl.write_text((source / "nvdaController.idl").read_text().replace(
"\n uuid(", "\n implicit_handle(handle_t controllerBinding),\n uuid("))
subprocess.run(["widl", "--win64", "-c", "-o", str(tempPath / "nvdaController_c.c"),
str(clientIdl)], check=True)
clientPath = tempPath / "controller-fixture.exe"
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-I" + str(tempPath),
"-I" + str(build / "generated"), "-o", str(clientPath),
str(source / "tests/controller_fixture.c"), "-lrpcrt4"], check=True)
helper = None
client = None
helperCommand = [str(build / "cthulhu-wine-access.exe"), "--no-dialog-reader",
*(["--reader", args.reader] if args.reader else [])]
try:
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
# Even when the other reader exists, the selected reader is required.
assert name_call("RequestName", otherInterface) == 1
result = subprocess.run(helperCommand, env=env, timeout=5)
assert result.returncode == 1, "helper accepted an unselected reader"
for readerArgs in (["--reader"], ["--reader", "unknown"]):
result = subprocess.run([str(build / "cthulhu-wine-access.exe"), *readerArgs],
env=env, timeout=5)
assert result.returncode == 1, "invalid reader argument was accepted"
assert name_call("RequestName", interface) == 1
helper = subprocess.Popen(helperCommand, env=env)
# The fixture waits for the RPC server to listen before making calls.
client = subprocess.Popen(["wine", str(clientPath)], env=env)
wait_for_client(client)
assert helper.poll() is None, "helper exited during controller requests"
assert events == [
("SpeakText", ("Controller speech", False)),
("CancelSpeech", ()),
("BrailleMessage", ("Controller braille",)),
("SpeakSsml", ("wine-nvda-controller", "<speak>Controller SSML</speak>")),
], events
assert name_call("ReleaseName", interface) == 1
client = subprocess.Popen(["wine", str(clientPath), "unavailable"], env=env)
wait_for_client(client)
print(f"PASS: {interface} controller routing, SSML status, service loss, and reader validation")
finally:
for child in (client, helper):
if child is not None and child.poll() is None:
child.terminate()
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=3)
subprocess.run(["wineserver", "-k"], env=env, timeout=5)
+7 -4
View File
@@ -17,6 +17,7 @@ from gi.repository import Gio, GLib
parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build")
parser.add_argument("--session", action="store_true")
parser.add_argument("--reader", choices=("cthulhu", "orca"))
parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu")
parser.add_argument("--notepadpp-exe", type=Path)
parser.add_argument("--goldwave-exe", type=Path)
@@ -37,14 +38,16 @@ if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture,
*(["--reader", args.reader] if args.reader else []),
*(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe else []),
*(["--goldwave-exe", str(args.goldwave_exe.resolve())] if args.goldwave_exe else []),
*(["--32-bit"] if args.is32Bit else []),
*(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])]))
interface = "org.stormux.Cthulhu1.Service"
object_path = "/org/stormux/Cthulhu1/Service"
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
object_path = "/" + interface.replace(".", "/")
readerArguments = ["--reader", args.reader] if args.reader else []
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]):
xml += f'<arg name="arg{index}" type="{kind}" direction="in"/>'
@@ -107,7 +110,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
while not ready.exists() and app.poll() is None and time.monotonic() < deadline:
time.sleep(0.01)
assert ready.exists(), "editor did not become ready before helper attachment"
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller", *readerArguments], env=env)
# Allow the helper to register its bridge before opening the fixture.
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
@@ -125,7 +128,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
if args.restart_helper and not restarted and any(e[0].endswith(":text") for e in events):
helper.terminate()
helper.wait(timeout=5)
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller", *readerArguments], env=env)
restarted = True
time.sleep(0.01)
assert app.poll() == 0, "fixture failed or timed out"
+37 -7
View File
@@ -12,13 +12,17 @@ parser = argparse.ArgumentParser()
parser.add_argument('--build-dir', default='build')
parser.add_argument('--32-bit', dest='is32', action='store_true')
parser.add_argument('--session', action='store_true')
parser.add_argument('--reader', choices=('cthulhu', 'orca'))
args = parser.parse_args()
build = Path(args.build_dir).resolve()
if not args.session:
sys.exit(subprocess.call(['dbus-run-session', '--', sys.executable, __file__,
'--session', '--build-dir', str(build), *(['--32-bit'] if args.is32 else [])]))
iface = 'org.stormux.Cthulhu1.Service'
path = '/org/stormux/Cthulhu1/Service'
'--session', '--build-dir', str(build),
*(['--reader', args.reader] if args.reader else []),
*(['--32-bit'] if args.is32 else [])]))
iface = 'org.gnome.Orca1.Service' if args.reader == 'orca' else 'org.stormux.Cthulhu1.Service'
path = '/' + iface.replace('.', '/')
readerArguments = ['--reader', args.reader] if args.reader else []
capture_iface = 'org.stormux.Cthulhu1.WineReview'
capture_path = '/org/stormux/Cthulhu1/WineReview'
@@ -85,7 +89,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
return result[0]
try:
subprocess.run(['wineboot','-u'],env=env,check=True,timeout=45)
helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env)
helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller',*readerArguments],env=env)
app = subprocess.Popen(['wine',str(fixture)],env=env)
pump_until(lambda: any(f[4]=='Wine review fixture' for f in focuses.values()),8)
reply, elapsed = capture()
@@ -110,11 +114,37 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
print('PASS: test_capture_wire_contract')
other = Gio.DBusConnection.new_for_address_sync(os.environ['DBUS_SESSION_BUS_ADDRESS'],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT|Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,None,None)
# Owning the unselected reader name must not authorize capture.
otherIface = 'org.stormux.Cthulhu1.Service' if args.reader == 'orca' else 'org.gnome.Orca1.Service'
other.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','RequestName',
GLib.Variant('(su)',(otherIface,0)),None,Gio.DBusCallFlags.NONE,1000,None)
sender,focus = next((s,f) for s,f in focuses.items() if f[4]=='Wine review fixture')
wrong = request(other,sender,focus); pump_until(lambda:bool(wrong),2)
assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0])
other.close_sync(None)
print('PASS: test_wrong_caller')
# Move the selected name to a new connection. The helper must register
# again, authorize the new owner, and immediately reject the old owner.
other.register_object(path, Gio.DBusNodeInfo.new_for_xml(interface_xml()).interfaces[0],record,None,None)
providers.clear(); focuses.clear()
connection.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','ReleaseName',
GLib.Variant('(s)',(iface,)),None,Gio.DBusCallFlags.NONE,1000,None)
other.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','RequestName',
GLib.Variant('(su)',(iface,0)),None,Gio.DBusCallFlags.NONE,1000,None)
pump_until(lambda:sender in providers and sender in focuses)
focus=focuses[sender]
wrong=request(connection,sender,focus); pump_until(lambda:bool(wrong),2)
assert isinstance(wrong[0],GLib.Error) and 'AccessDenied' in str(wrong[0])
accepted=request(other,sender,focus); pump_until(lambda:bool(accepted),2)
assert not isinstance(accepted[0],GLib.Error), accepted[0]
assert accepted[0][0].unpack()[0]['status']=='ok'
providers.clear(); focuses.clear()
other.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','ReleaseName',
GLib.Variant('(s)',(iface,)),None,Gio.DBusCallFlags.NONE,1000,None)
connection.call_sync('org.freedesktop.DBus','/org/freedesktop/DBus','org.freedesktop.DBus','RequestName',
GLib.Variant('(su)',(iface,0)),None,Gio.DBusCallFlags.NONE,1000,None)
pump_until(lambda:sender in providers and sender in focuses)
other.close_sync(None)
print('PASS: test_reader_owner_replacement')
configure(33)
reply,_ = capture(); data=reply.unpack()[0]
assert data['status']=='ok', data['status']
@@ -152,7 +182,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
helper.terminate(); helper.wait(timeout=3)
subprocess.run(['wine',str(fixture),'--check-remote'],env=env,check=True,timeout=5)
print('PASS: test_native_buffer_lifetime_after_helper_stop')
helper=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env)
helper=subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller',*readerArguments],env=env)
pump_until(lambda:any(s != sender and f[4]=='Wine review fixture' for s,f in focuses.items()),5)
focuses.pop(sender,None)
configure(7)
@@ -180,7 +210,7 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
connection.unregister_object(registration)
registration = connection.register_object(path,Gio.DBusNodeInfo.new_for_xml(interface_xml(False)).interfaces[0],record,None,None)
events.clear()
helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller'],env=env)
helper = subprocess.Popen([str(build/'cthulhu-wine-access.exe'),'--no-controller',*readerArguments],env=env)
end=time.monotonic()+1
while time.monotonic()<end:
while GLib.MainContext.default().iteration(False): pass