Make d-bus available to orca if someone decides they want to implement it.
This commit is contained in:
1 parent
c3e981b4c2
commit
0f59809e9a
11 files changed
+374
-39
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user