Provide fresh Wine review snapshots and 32-bit Edit hooks
Expose visible Wine window text to the matching Go reader's independent keypad review commands, and extend standard Edit feedback to 32-bit programs such as MUSHclient without requiring 32-bit Linux GLib/Winelib libraries. Add lifetime-aware instance/window/control identities and bounded capture of standard Edit text, button/static captions, native ListBox/ListView items, and usable MSAA text. Preserve focus, caret, selection, scrolling, and the clipboard. Exclude hidden/password content before retrieval, retain both rune and UTF-16 offsets, clip visible ranges, and reject changing or oversized sources rather than returning incomplete text as a full snapshot. Export CaptureWindow on the version-1 WineReview D-Bus interface. Register with the current reader service owner and publish copied focus metadata. Authorize capture only for the current same-user owner. Use a dedicated GLib dispatcher and separate COM worker with a 500 ms deadline, one active job and one pending job, bounded payloads, and explicit stale/busy/limited/ unavailable replies. Keep menu and focus forwarding responsive when an MSAA provider stalls; retain ordinary feedback with older readers. Read the actual X11 geometry associated with Wine HWNDs using bounded XCB queries and __wine_x11_whole_window. Translate virtual-desktop coordinates and recheck physical geometry before returning captured text. Link XCB in the helper/tests and declare libxcb in the Arch package dependencies. Build edit_hook.c into the 32-bit DLL and attach both menu and input hooks to the foreground 32-bit UI thread. Release both hooks on transitions or attachment failure. Keep Scintilla-specific hooks confined to 64-bit builds. Expand Edit integration checks with read-only multiline navigation and unsuccessful deletion, plus 32-bit editor/Windows Forms lifecycle support. Add 32-bit/64-bit native capture fixtures, wire-protocol integration checks, and design/implementation documents. Document exact schema, capture limits, installation dependencies, compatibility, and live acceptance procedures. Validation during commit preparation: - git diff --check passed. - cmake --build build --parallel passed. - CTest could not start because xvfb-run is unavailable in this environment. - Further tests were skipped at the user's request; capture, protocol, and menu/editor integration tests were not run during commit preparation. Live speech and physical braille checks remain necessary, including review without moving the native caret, incoming output, plugin dialogs, and return to Linux/Fenrir. Custom painted output requires an accessible provider; RichEdit/Scintilla capture, semantic object review, Say All, typing echo, and native Wine braille routing remain unsupported by this protocol.
This commit is contained in:
1 parent
872388a59b
commit
a8cb350467
21 files changed
+2401
-38
No files matched your search
@@ -0,0 +1,184 @@
|
||||
"""Read-only review protocol checks in an isolated Wine prefix/session bus."""
|
||||
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('--32-bit', dest='is32', 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), *(['--32-bit'] if args.is32 else [])]))
|
||||
iface = 'org.stormux.Cthulhu1.Service'
|
||||
path = '/org/stormux/Cthulhu1/Service'
|
||||
capture_iface = 'org.stormux.Cthulhu1.WineReview'
|
||||
capture_path = '/org/stormux/Cthulhu1/WineReview'
|
||||
|
||||
def interface_xml(review=True):
|
||||
methods = {'PresentAccessibleEvent': ['s']*7+['i','i','s']}
|
||||
if review:
|
||||
methods.update(RegisterWineReviewProvider=['o','u'], PresentWineReviewFocus=['s','s','s','t','s','s','b','(iiii)'])
|
||||
return '<node><interface name="'+iface+'">'+''.join(
|
||||
'<method name="'+name+'">'+''.join('<arg type="'+kind+'" direction="in"/>' for kind in kinds)+
|
||||
'<arg type="b" direction="out"/></method>' for name,kinds in methods.items())+'</interface></node>'
|
||||
|
||||
providers, focuses, events = {}, {}, []
|
||||
def record(conn, sender, object_path, interface, method, params, invocation):
|
||||
data = params.unpack()
|
||||
accepted = True
|
||||
if method == 'RegisterWineReviewProvider':
|
||||
accepted = data == (capture_path, 1)
|
||||
if accepted: providers[sender] = data
|
||||
elif method == 'PresentWineReviewFocus':
|
||||
accepted = sender in providers
|
||||
if accepted: focuses[sender] = data
|
||||
else: events.append(data)
|
||||
invocation.return_value(GLib.Variant('(b)', (accepted,)))
|
||||
|
||||
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)
|
||||
|
||||
def pump_until(predicate, seconds=5):
|
||||
end = time.monotonic()+seconds
|
||||
while not predicate() and time.monotonic()<end:
|
||||
while GLib.MainContext.default().iteration(False): pass
|
||||
time.sleep(.005)
|
||||
assert predicate(), f'private review protocol condition timed out; providers={providers}; helper={helper.poll()}; app={app.poll()}'
|
||||
|
||||
def request(conn, sender, focus):
|
||||
result = []
|
||||
started = time.monotonic()
|
||||
def finished(source, pending):
|
||||
try: result.append((source.call_finish(pending), time.monotonic()-started))
|
||||
except GLib.Error as error: result.append(error)
|
||||
conn.call(sender,capture_path,capture_iface,'CaptureWindow',GLib.Variant('(sst)',focus[1:4]),
|
||||
GLib.VariantType.new('(a{sv})'),Gio.DBusCallFlags.NONE,1500,None,finished)
|
||||
return result
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='wine-review-wire-') as temporary:
|
||||
env = os.environ.copy()
|
||||
env.update(WINEPREFIX=str(Path(temporary)/'prefix'),WINEDEBUG=os.environ.get('WINE_ACCESS_TEST_DEBUG','-all'),WINEDLLOVERRIDES='mscoree,mshtml=',CTHULHU_WINE_ACCESS_MANAGED='1')
|
||||
fixture = build/('review-fixture32.exe' if args.is32 else 'review-fixture64.exe')
|
||||
helper, app, secondary = None, None, None
|
||||
def configure(mode):
|
||||
subprocess.run(['wine',str(fixture),'--configure',str(mode)],env=env,check=True,timeout=5)
|
||||
while GLib.MainContext.default().iteration(False): pass
|
||||
def capture():
|
||||
end=time.monotonic()+.15
|
||||
while time.monotonic()<end:
|
||||
while GLib.MainContext.default().iteration(False): pass
|
||||
time.sleep(.005)
|
||||
sender, focus = next((s,f) for s,f in focuses.items() if f[4]=='Wine review fixture')
|
||||
result = request(connection,sender,focus)
|
||||
pump_until(lambda: bool(result),2)
|
||||
assert not isinstance(result[0],GLib.Error),f'{result[0]}; helper={helper.poll()}'
|
||||
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)
|
||||
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()
|
||||
fields = reply.unpack()[0]
|
||||
assert fields['status']=='ok' and fields['version']==1, fields
|
||||
assert elapsed<.8 and fields['nodes']
|
||||
wire = reply.get_child_value(0)
|
||||
assert wire.lookup_value('version',GLib.VariantType.new('u')) is not None
|
||||
assert wire.lookup_value('revision',GLib.VariantType.new('t')) is not None
|
||||
assert wire.lookup_value('nodes',GLib.VariantType.new('aa{sv}')) is not None
|
||||
nodes=wire.lookup_value('nodes',GLib.VariantType.new('aa{sv}'))
|
||||
for index in range(nodes.n_children()):
|
||||
node=nodes.get_child_value(index)
|
||||
for key,kind in {'id':'s','parent':'s','role':'s','name':'s','protected':'b','visible':'b',
|
||||
'text_known':'b','caret_known':'b','caret':'t','caret_utf16':'t',
|
||||
'bounds':'(iiii)','ranges':'a(tttts(iiii))'}.items():
|
||||
assert node.lookup_value(key,GLib.VariantType.new(kind)) is not None,(key,kind)
|
||||
strings = str(fields)
|
||||
assert 'alpha beta' in strings and 'output second' in strings and 'native & item' in strings
|
||||
assert 'private-review-secret' not in strings and 'hidden-review-secret' not in strings
|
||||
subprocess.run(['wine',str(fixture),'--check'],env=env,check=True,timeout=5)
|
||||
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)
|
||||
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')
|
||||
configure(9)
|
||||
reply,_ = capture(); data=reply.unpack()[0]
|
||||
assert data['status'] in ('ok','stale')
|
||||
if data['status']=='stale': assert not data['nodes']
|
||||
else: assert 'omega zeta' in str(data) and 'alpha beta' not in str(data)
|
||||
print('PASS: test_same_length_capture_race')
|
||||
configure(21)
|
||||
reply,elapsed=capture(); data=reply.unpack()[0]
|
||||
assert data['status']=='unavailable' and not data['nodes'] and elapsed<.8,(data,elapsed)
|
||||
subprocess.run(['wine',str(fixture),'--check-remote'],env=env,check=True,timeout=5)
|
||||
print('PASS: test_native_buffer_lifetime_after_deadline')
|
||||
# Exiting the helper during a custom message must not invalidate the
|
||||
# application's remote allocation while its window procedure runs.
|
||||
configure(21)
|
||||
sender,focus=next((s,f) for s,f in focuses.items() if f[4]=='Wine review fixture')
|
||||
request(connection,sender,focus)
|
||||
end=time.monotonic()+.2
|
||||
while time.monotonic()<end:
|
||||
while GLib.MainContext.default().iteration(False): pass
|
||||
time.sleep(.005)
|
||||
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)
|
||||
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)
|
||||
reply,_ = capture(); data=reply.unpack()[0]
|
||||
assert data['status']=='limited' and not data['nodes']
|
||||
configure(10)
|
||||
sender,focus = next((s,f) for s,f in focuses.items() if f[4]=='Wine review fixture')
|
||||
requests = [request(connection,sender,focus) for _ in range(5)]
|
||||
secondary = subprocess.Popen(['wine',str(fixture),'--secondary'],env=env)
|
||||
pump_until(lambda:all(requests),2)
|
||||
statuses = [result[0][0].unpack()[0]['status'] for result in requests]
|
||||
assert 'busy' in statuses, statuses
|
||||
assert 'unavailable' in statuses, statuses
|
||||
for result in requests:
|
||||
assert result[0][1]<.8, result[0][1]
|
||||
assert not result[0][0].unpack()[0]['nodes']
|
||||
print('PASS: test_busy_capture; test_capture_deadline')
|
||||
pump_until(lambda:any(event[9]=='Wine secondary fixture' and event[1]=='focus' for event in events),1)
|
||||
print('PASS: test_stalled_capture_keeps_focus_speech')
|
||||
started=time.monotonic(); helper.terminate(); helper.wait(timeout=3)
|
||||
assert time.monotonic()-started<3
|
||||
print('PASS: test_stop_during_capture')
|
||||
secondary.terminate(); secondary.wait(timeout=3)
|
||||
time.sleep(2)
|
||||
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)
|
||||
end=time.monotonic()+1
|
||||
while time.monotonic()<end:
|
||||
while GLib.MainContext.default().iteration(False): pass
|
||||
time.sleep(.005)
|
||||
configure(11)
|
||||
pump_until(lambda:bool(events),5)
|
||||
assert helper.poll() is None
|
||||
print('PASS: test_old_reader')
|
||||
finally:
|
||||
for process in (app,helper,secondary):
|
||||
if process is not None and process.poll() is None:
|
||||
process.terminate()
|
||||
try: process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired: process.kill(); process.wait(timeout=3)
|
||||
subprocess.run(['wineserver','-k'],env=env,timeout=5)
|
||||
Reference in new issue
Block a user