Make the process of adding an orca extension easier.

This commit is contained in:
Storm Dragon committed 2026-10-14 03:35:00 -04:00
1 parent a8118c12cf
commit 417ee40dda
6 files changed
+199 -35

No files matched your search

+17
View File
@@ -102,12 +102,29 @@ 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.
It also rejects invalid endpoint overrides, missing objects/interfaces,
missing required methods and incorrect input/output signatures.
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.
To check an Orca extension endpoint, add `--extension-endpoint`:
```sh
xvfb-run -a python3 tests/controller_integration.py --build-dir build --reader orca --extension-endpoint
xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca --extension-endpoint
xvfb-run -a python3 tests/menu_integration.py --build-dir build --reader orca --extension-endpoint --fixture edit
xvfb-run -a python3 tests/review_integration.py --build-dir build --reader orca --extension-endpoint
```
These publish `/org/gnome/Orca1/Service/WineAccess` with interface
`org.gnome.Orca1.WineAccess`, while retaining the `org.gnome.Orca1.Service`
bus name. Controller checks put overrides before reader selection; control
and review checks put them after it. Review checks still reject unrelated
callers and transfer capture access only when the selected bus owner changes.
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
+46 -9
View File
@@ -12,17 +12,21 @@ 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("--extension-endpoint", action="store_true")
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),
*(["--extension-endpoint"] if args.extension_endpoint else []),
*(["--reader", args.reader] if args.reader else [])]))
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName
otherInterface = "org.stormux.Cthulhu1.Service" if args.reader == "orca" else "org.gnome.Orca1.Service"
objectPath = "/" + interface.replace(".", "/")
objectPath = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_endpoint else "")
endpointArguments = ["--reader-path", objectPath, "--reader-interface", interface] if args.extension_endpoint else []
methods = {"SpeakText": ("sb", "b"), "CancelSpeech": ("", "b"),
"BrailleMessage": ("s", "b"), "SpeakSsml": ("ss", "i")}
xml = '<node><interface name="' + interface + '">'
@@ -41,7 +45,7 @@ def record(connection, sender, path, iface, method, parameters, invocation):
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
registration = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
def name_call(method, name):
@@ -51,13 +55,13 @@ def name_call(method, name):
Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
def wait_for_client(client):
def wait_for_client(client, expected=0):
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()}"
assert client.poll() == expected, f"client failed or timed out: {client.poll()}"
with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
@@ -80,6 +84,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
helper = None
client = None
helperCommand = [str(build / "cthulhu-wine-access.exe"), "--no-dialog-reader",
*endpointArguments,
*(["--reader", args.reader] if args.reader else [])]
try:
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
@@ -87,11 +92,43 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
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"]):
for readerArgs in (["--reader"], ["--reader", "unknown"],
["--reader-path"], ["--reader-path", ""],
["--reader-path", "relative/path"], ["--reader-path", "/bad-path"],
["--reader-interface"], ["--reader-interface", ""],
["--reader-interface", "WineAccess"],
["--reader-interface", "org.example.Bad-Interface"]):
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
assert result.returncode == 1, "invalid endpoint argument was accepted"
assert name_call("RequestName", busName) == 1
# An owned reader name is insufficient: refuse missing objects,
# interfaces, methods and incompatible signatures before starting RPC.
def reject(command):
rejected = subprocess.Popen(command, env=env, stderr=subprocess.PIPE)
try:
wait_for_client(rejected, 1)
return rejected.communicate()[1].decode()
finally:
if rejected.poll() is None:
rejected.kill()
rejected.wait()
diagnostic = reject([*helperCommand, "--reader-path", "/Missing"])
# Gio can introspect an absent object as an empty node.
assert "reader object" in diagnostic or "reader interface" in diagnostic, diagnostic
assert "reader interface" in reject([*helperCommand, "--reader-interface", "org.example.Missing"])
connection.unregister_object(registration)
for brokenXml in (
xml.replace('<method name="CancelSpeech"><arg type="b" direction="out"/></method>', ''),
xml.replace('<arg type="i" direction="out"/>', '<arg type="b" direction="out"/>'),
xml.replace('<arg type="b" direction="in"/>', '<arg type="s" direction="in"/>')):
broken = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(brokenXml).interfaces[0], record, None, None)
assert "reader method" in reject(helperCommand)
connection.unregister_object(broken)
registration = connection.register_object(objectPath, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
# Controller-only interfaces must not enable control reading.
assert "PresentAccessibleEvent" in reject([part for part in helperCommand if part != "--no-dialog-reader"])
print("PASS: endpoint validation and required method signatures")
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)
@@ -103,7 +140,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-controller-") as temporary:
("BrailleMessage", ("Controller braille",)),
("SpeakSsml", ("wine-nvda-controller", "<speak>Controller SSML</speak>")),
], events
assert name_call("ReleaseName", interface) == 1
assert name_call("ReleaseName", busName) == 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")
+8 -3
View File
@@ -18,6 +18,7 @@ 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("--extension-endpoint", action="store_true")
parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "listbox", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu")
parser.add_argument("--notepadpp-exe", type=Path)
parser.add_argument("--goldwave-exe", type=Path)
@@ -38,6 +39,7 @@ if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture,
*(["--extension-endpoint"] if args.extension_endpoint else []),
*(["--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 []),
@@ -45,9 +47,12 @@ if not args.session:
*(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])]))
interface = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
object_path = "/" + interface.replace(".", "/")
busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName
object_path = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_endpoint else "")
readerArguments = ["--reader", args.reader] if args.reader else []
if args.extension_endpoint:
readerArguments += ["--reader-path", object_path, "--reader-interface", interface]
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"/>'
@@ -62,7 +67,7 @@ connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(object_path, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "RequestName",
GLib.Variant("(su)", (interface, 0)), None,
GLib.Variant("(su)", (busName, 0)), None,
Gio.DBusCallFlags.NONE, 1000, None)
with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
+12 -7
View File
@@ -13,16 +13,21 @@ 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'))
parser.add_argument('--extension-endpoint', 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),
*(['--extension-endpoint'] if args.extension_endpoint else []),
*(['--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('.', '/')
busName = 'org.gnome.Orca1.Service' if args.reader == 'orca' else 'org.stormux.Cthulhu1.Service'
iface = busName.rsplit('.', 1)[0] + '.WineAccess' if args.extension_endpoint else busName
path = '/' + busName.replace('.', '/') + ('/WineAccess' if args.extension_endpoint else '')
readerArguments = ['--reader', args.reader] if args.reader else []
if args.extension_endpoint:
readerArguments += ['--reader-path', path, '--reader-interface', iface]
capture_iface = 'org.stormux.Cthulhu1.WineReview'
capture_path = '/org/stormux/Cthulhu1/WineReview'
@@ -50,7 +55,7 @@ def record(conn, sender, object_path, interface, method, params, invocation):
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
registration = connection.register_object(path, Gio.DBusNodeInfo.new_for_xml(interface_xml()).interfaces[0], record, None, 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)
GLib.Variant('(su)',(busName,0)),None,Gio.DBusCallFlags.NONE,1000,None)
def pump_until(predicate, seconds=5):
end = time.monotonic()+seconds
@@ -127,9 +132,9 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
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)
GLib.Variant('(s)',(busName,)),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)
GLib.Variant('(su)',(busName,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)
@@ -139,9 +144,9 @@ with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
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)
GLib.Variant('(s)',(busName,)),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)
GLib.Variant('(su)',(busName,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')