#!/usr/bin/env python3 """Isolated X11 command consumption and pass-through proof; never uses desktop DISPLAY.""" import json import os import ctypes from pathlib import Path import subprocess import sys import tempfile import time def wait_for(check, message, timeout=15): deadline = time.monotonic() + timeout while time.monotonic() < deadline: value = check() if value: return value time.sleep(0.02) raise RuntimeError(message) def count_records(path, *needles): return sum(all(needle in line for needle in needles) for line in path.read_text().splitlines()) def press_physical_key(symbol): # xdotool's keysym synthesis can toggle NumLock between press and release. # XTest the physical keycode without synthesizing any modifier changes. x11 = ctypes.CDLL('libX11.so.6') xtst = ctypes.CDLL('libXtst.so.6') x11.XOpenDisplay.argtypes = [ctypes.c_char_p] x11.XOpenDisplay.restype = ctypes.c_void_p x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong] x11.XKeysymToKeycode.restype = ctypes.c_ubyte x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] x11.XCloseDisplay.argtypes = [ctypes.c_void_p] xtst.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong] display = x11.XOpenDisplay(None) if not display: raise RuntimeError('private X11 display unavailable') try: code = x11.XKeysymToKeycode(display, symbol) assert code, 'physical key missing from private keymap' assert xtst.XTestFakeKeyEvent(display, code, 1, 0) assert xtst.XTestFakeKeyEvent(display, code, 0, 0) x11.XSync(display, 0) finally: x11.XCloseDisplay(display) def set_active_window(window_id): x11 = ctypes.CDLL('libX11.so.6') x11.XOpenDisplay.argtypes = [ctypes.c_char_p] x11.XOpenDisplay.restype = ctypes.c_void_p x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] x11.XDefaultRootWindow.restype = ctypes.c_ulong x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int] x11.XInternAtom.restype = ctypes.c_ulong x11.XChangeProperty.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ] x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] x11.XCloseDisplay.argtypes = [ctypes.c_void_p] display = x11.XOpenDisplay(None) if not display: raise RuntimeError('private X11 display unavailable') try: root = x11.XDefaultRootWindow(display) property_atom = x11.XInternAtom(display, b'_NET_ACTIVE_WINDOW', False) window_atom = x11.XInternAtom(display, b'WINDOW', False) active_window = ctypes.c_ulong(window_id) x11.XChangeProperty( display, root, property_atom, window_atom, 32, 0, ctypes.byref(active_window), 1, ) x11.XSync(display, 0) finally: x11.XCloseDisplay(display) def set_window_class(window_id, instance_name, class_name): x11 = ctypes.CDLL('libX11.so.6') x11.XOpenDisplay.argtypes = [ctypes.c_char_p] x11.XOpenDisplay.restype = ctypes.c_void_p x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p] x11.XDefaultRootWindow.restype = ctypes.c_ulong x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int] x11.XInternAtom.restype = ctypes.c_ulong x11.XChangeProperty.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ] x11.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] x11.XCloseDisplay.argtypes = [ctypes.c_void_p] display = x11.XOpenDisplay(None) if not display: raise RuntimeError('private X11 display unavailable') try: window = ctypes.c_ulong(window_id) property_atom = x11.XInternAtom(display, b'WM_CLASS', False) string_atom = x11.XInternAtom(display, b'STRING', False) class_data = ctypes.create_string_buffer(f'{instance_name}\0{class_name}\0'.encode()) x11.XChangeProperty( display, window, property_atom, string_atom, 8, 0, class_data, len(class_data) - 1, ) x11.XSync(display, 0) finally: x11.XCloseDisplay(display) def check_speech_mode_shortcut(log, events, modifier): for modifier_first in (True, False): before_s = events.read_text().count(', s)') before_command = count_records(log, '"message":"keyboard command received"', '"command":"cycle-speech-mode"') subprocess.run(['xdotool', 'keydown', modifier, 'keydown', 's', 'keydown', 's'], check=True) release_keys = [modifier, 's'] if modifier_first else ['s', modifier] subprocess.run(['xdotool', 'keyup', release_keys[0], 'keyup', release_keys[1]], check=True) wait_for(lambda: count_records(log, '"message":"keyboard command received"', '"command":"cycle-speech-mode"') == before_command + 1, 'speech mode shortcut did not dispatch once') time.sleep(0.05) assert events.read_text().count(', s)') == before_s, 'speech mode shortcut leaked S' def run_inside(directory): root = Path(directory) config = root / "settings.toml" config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 1\n') speech_modes_only = os.environ.get('CTHULHU_NATIVE_SPEECH_MODES_ONLY') == '1' if speech_modes_only: config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 1\n' 'cthulhuModifierKeys = ["Insert", "KP_Insert", "Caps_Lock"]\n') log = root / "cthulhu.log" events = root / "keys.log" binary = Path(os.environ.get('CTHULHU_BINARY', str(Path(__file__).resolve().parents[1] / "cthulhu"))) processes = [] try: with events.open("w") as output: probe = subprocess.Popen(['stdbuf', '-oL', 'xev', '-name', 'CthulhuInputProbe', '-event', 'keyboard'], stdout=output, stderr=subprocess.DEVNULL) processes.append(probe) def find_window(): result = subprocess.run(['xdotool', 'search', '--name', '^CthulhuInputProbe$'], capture_output=True, text=True) return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None window = wait_for(find_window, 'probe window missing') subprocess.run(['xdotool', 'windowfocus', window], check=True) nativeTest = os.environ.get('CTHULHU_XRECORD_TEST') if nativeTest: subprocess.run([nativeTest, '-test.run', '^TestNative(ModifierPressRelease|CommandGrabSuspensionAndRestoration|BrowseKeyTransitions|DispatchBeforeRelease|CustomBindingsUpdateAndRelease|TentacleBindingsUpdateAndRelease|ModifierConfigurationUpdate|LockStatesAndShiftCapsLock|XTermHandoffKeepsKeypadReleased|SuspendedQueuedKey|LearnModeCapturesKeysAndRestoresCommands|SecureInputDropsKeysAndRestoresGrabs)$', '-test.timeout=40s'], env=dict(os.environ, CTHULHU_NATIVE_X11='1', CTHULHU_NATIVE_BROWSER='1'), check=True, timeout=45, cwd=Path(__file__).resolve().parents[1] / 'internal/platform/xorg') if nativeTest and os.environ.get('CTHULHU_NATIVE_LISTENER_ONLY') == '1': print('PASS: native command bindings, press/release ordering, browse transitions, suspension, custom updates, and grab rollback') return app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(app) def ready(): if app.poll() is not None: raise RuntimeError(log.read_text() if log.exists() else 'Cthulhu exited before logging') return log.exists() and 'listening for focus events' in log.read_text() wait_for(ready, 'Cthulhu startup timed out') if speech_modes_only: for layout in (1, 2): for modifier in ('Insert', 'KP_Insert', 'Caps_Lock'): check_speech_mode_shortcut(log, events, modifier) before_s = events.read_text().count(', s)') subprocess.run(['xdotool', 'key', 's'], check=True) wait_for(lambda: events.read_text().count(', s)') == before_s + 2, 'ordinary S stayed grabbed after speech mode shortcut') before_save = count_records(log, '"message":"speech setting command received"', '"command":"save-speech-settings"') subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'Control_L', 'keydown', 'Shift_L', 'key', 's', 'keyup', 'Shift_L', 'keyup', 'Control_L', 'keyup', 'Insert'], check=True) wait_for(lambda: count_records(log, '"message":"speech setting command received"', '"command":"save-speech-settings"') == before_save + 1, 'speech mode shortcut broke speech settings save') subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) assert app.wait(timeout=5) == 0, log.read_text() print(f'PASS: layout {layout} speech-mode shortcut, three modifiers, repeat, release orders, ' 'ordinary S, speech settings save, and quit') if layout == 1: config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 2\n' 'cthulhuModifierKeys = ["Insert", "KP_Insert", "Caps_Lock"]\n') log = root / 'speech-mode-laptop.log' app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(app) wait_for(ready, 'laptop startup timed out') return selection_probe = subprocess.Popen([ sys.executable, str(Path(__file__).with_name('gtk_selection.py')), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(selection_probe) def find_selection_window(): result = subprocess.run( ['xdotool', 'search', '--name', '^Cthulhu Selection Probe$'], capture_output=True, text=True, ) return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None selection_window = wait_for(find_selection_window, 'selection probe window missing') subprocess.run(['xdotool', 'windowfocus', selection_window], check=True) try: wait_for( lambda: any( '"message":"queued accessible presentation"' in line and '"type":"object:selection-changed"' in line for line in log.read_text().splitlines() ), 'focused GTK selection was not presented', ) except RuntimeError: print(log.read_text(), file=sys.stderr) raise assert selection_probe.wait(timeout=5) == 0, 'GTK selection probe failed' print('PASS: focused GTK selection resolved to its selected child') xterm = subprocess.Popen( [ 'xterm', '-T', 'Cthulhu XTerm Handoff', '-e', sys.executable, '-c', ( 'import pathlib, sys, tty\n' 'tty.setcbreak(sys.stdin)\n' "while sys.stdin.read(1) != 'q':\n" ' pass\n' f"pathlib.Path({str(root / 'xterm-key.log')!r}).write_text('q')\n" ), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) processes.append(xterm) def find_xterm(): result = subprocess.run( ['xdotool', 'search', '--onlyvisible', '--name', '^Cthulhu XTerm Handoff$'], capture_output=True, text=True, ) return result.stdout.strip().splitlines()[0] if result.stdout else None xterm_window = wait_for(find_xterm, 'XTerm handoff window missing') def focus_xterm(): result = subprocess.run( ['xdotool', 'windowfocus', '--sync', xterm_window], capture_output=True, text=True, ) return result.returncode == 0 wait_for(focus_xterm, 'XTerm handoff window could not be focused') set_active_window(int(xterm_window)) wait_for(lambda: xterm.poll() is None, 'XTerm key-recording child exited before focus handoff') wait_for( lambda: 'suspended for XTerm focus handoff' in log.read_text(), 'XTerm focus did not suspend Cthulhu', ) subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) xterm_key = root / 'xterm-key.log' wait_for(lambda: xterm_key.exists(), 'Insert+Q did not reach the focused XTerm') assert xterm_key.read_text() == 'q', 'XTerm received an unexpected key' assert app.poll() is None, 'Cthulhu consumed Insert+Q while XTerm owned focus' print('PASS: XTerm focus suspended Cthulhu and passed through Insert+Q') subprocess.run(['xdotool', 'windowfocus', '--sync', window], check=True) set_active_window(int(window)) before_unknown_q = events.read_text().count('keysym 0x71, q)') subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) wait_for( lambda: events.read_text().count('keysym 0x71, q)') == before_unknown_q + 2, 'unknown active window restored Cthulhu command grabs', ) assert app.poll() is None, 'unknown active window stopped Cthulhu' print('PASS: unknown active window remained suspended for Fenrir handoff') set_window_class( int(window), 'CthulhuInputProbe', 'CthulhuInputProbe', ) subprocess.run(['xdotool', 'windowfocus', '--sync', window], check=True) set_active_window(int(window)) wait_for( lambda: 'resumed after XTerm focus handoff' in log.read_text(), 'leaving XTerm did not restore Cthulhu', ) assert app.poll() is None, 'Cthulhu exited while restoring command grabs' subprocess.run(['xdotool', 'key', 'q', 'h', 'shift+h', 'b', 'x', 'l', 'Tab', 'Control_L', 'Shift_L'], check=True) wait_for(lambda: 'keysym 0x71, q)' in events.read_text(), 'ordinary q was consumed') assert app.poll() is None, 'ordinary typing quit Cthulhu' before = events.read_text().count('keysym 0x71, q)') assert before >= 2 and before % 2 == 0, ('ordinary q press/release imbalance', before) before_x = events.read_text().count('keysym 0x78, x)') before_insert = events.read_text().count('keysym 0xff63, Insert)') subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'x', 'keyup', 'Insert', 'keyup', 'x'], check=True) wait_for(lambda: events.read_text().count('keysym 0x78, x)') == before_x + 2, 'unbound Insert+x did not reach application') wait_for(lambda: events.read_text().count('keysym 0xff63, Insert)') == before_insert + 2, 'unbound Insert press/release was not balanced') assert app.poll() is None, 'unbound Insert chord stopped Cthulhu' before_return = events.read_text().count('keysym 0xff0d, Return)') before_insert = events.read_text().count('keysym 0xff63, Insert)') subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'Return', 'keyup', 'Insert'], check=True) wait_for(lambda: events.read_text().count('keysym 0xff0d, Return)') == before_return + 2, 'desktop Insert+Return did not reach application') wait_for(lambda: events.read_text().count('keysym 0xff63, Insert)') == before_insert + 2, 'desktop Insert+Return was not balanced') assert log.read_text().count('where am I command received') == 0, 'desktop Insert+Return incorrectly routed Where Am I' before_keypad_enter = events.read_text().count('keysym 0xff8d, KP_Enter)') subprocess.run(['xdotool', 'key', 'KP_Enter'], check=True) wait_for(lambda: log.read_text().count('where am I command received') == 1, 'desktop KP Enter did not route Where Am I') time.sleep(0.05) assert events.read_text().count('keysym 0xff8d, KP_Enter)') == before_keypad_enter, 'desktop Where Am I KP Enter leaked to application' subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) subprocess.run(['xdotool', 'key', 'KP_Enter'], check=True) wait_for(lambda: log.read_text().count('where am I command received') == 2, 'NumLock prevented desktop KP Enter Where Am I') time.sleep(0.05) assert events.read_text().count('keysym 0xff8d, KP_Enter)') == before_keypad_enter, 'NumLock desktop KP Enter leaked to application' subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) assert app.poll() is None, 'where am I command stopped Cthulhu' before_space = events.read_text().count('keysym 0x20, space)') before_insert = events.read_text().count('keysym 0xff63, Insert)') subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'space', 'keydown', 'space', 'keyup', 'Insert', 'keyup', 'space'], check=True) wait_for(lambda: log.read_text().count('preferences command received') == 1, 'preferences command was not routed exactly once') time.sleep(0.05) assert events.read_text().count('keysym 0x20, space)') == before_space, 'preferences Space leaked to application' assert events.read_text().count('keysym 0xff63, Insert)') == before_insert, 'preferences Insert leaked to application' assert app.poll() is None, 'preferences command stopped Cthulhu' def preferences_window(): result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name', '^Cthulhu Preferences$'], capture_output=True, text=True) return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None preferences = wait_for(preferences_window, 'preferences window did not open') subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'space', 'keyup', 'Insert'], check=True) wait_for(lambda: log.read_text().count('preferences command received') == 2, 'second preferences command missing') assert preferences_window() == preferences, 'repeated command replaced the preferences window' set_active_window(int(preferences)) subprocess.run(['xdotool', 'windowfocus', preferences], check=True) focus_log_offset = len(log.read_text()) focus_before = log.read_text().count('"type":"object:state-changed:focused"') def preferences_control(controlName, controlRole): import pyatspi def walk(node): yield node for child in node: if child is not None: yield from walk(child) for application in pyatspi.Registry.getDesktop(0): for window in application: if window is None or window.name != 'Cthulhu Preferences': continue for node in walk(window): if node.name == controlName and node.getRole() == controlRole: return node return None import pyatspi progressBackground = wait_for(lambda: preferences_control('Report background progress bars', pyatspi.ROLE_CHECK_BOX), 'preferences progress background option missing') assert progressBackground.queryComponent().grabFocus(), 'cannot focus preferences progress background option' wait_for(lambda: '"role":"check box"' in log.read_text()[focus_log_offset:], 'preferences progress background focus event missing') subprocess.run(['xdotool', 'key', '--delay', '100', *(["Tab"] * 12)], check=True) try: wait_for( lambda: log.read_text().count('"type":"object:state-changed:focused"') >= focus_before + 8, 'preferences Tab traversal stopped producing focus events', ) except RuntimeError: focused = [ line for line in log.read_text().splitlines() if '"type":"object:state-changed:focused"' in line ] print("\n".join(focused[-16:]), file=sys.stderr) raise focus_log = log.read_text()[focus_log_offset:] assert '"role":"combo box"' in focus_log, ( 'preferences combo focused as an internal button', focus_log, ) combo_snapshot_durations = [] for line in focus_log.splitlines(): if '"role":"combo box"' not in line or '"type":"object:state-changed:focused"' not in line: continue record = json.loads(line) if 'snapshot_duration' in record: combo_snapshot_durations.append(record['snapshot_duration']) assert combo_snapshot_durations, 'preferences combo focus snapshots missing durations' slowest_combo_snapshot = max(combo_snapshot_durations) assert slowest_combo_snapshot < 100_000_000, ( 'preferences combo focus snapshot was slow', slowest_combo_snapshot, ) progress_snapshot_durations = [] for line in focus_log.splitlines(): if '"role":"check box"' not in line or '"type":"object:state-changed:focused"' not in line: continue record = json.loads(line) if 'snapshot_duration' in record: progress_snapshot_durations.append(record['snapshot_duration']) assert progress_snapshot_durations, 'preferences progress checkbox focus snapshots missing durations' slowest_progress_snapshot = max(progress_snapshot_durations) assert slowest_progress_snapshot < 20_000_000, ( 'preferences progress checkbox focus snapshot was slow', slowest_progress_snapshot, ) # Start at the retained keyboard layout group and verify the # current General page's actual keyboard path. desktop = wait_for(lambda: preferences_control('Desktop', pyatspi.ROLE_RADIO_BUTTON), 'preferences Desktop layout missing') assert desktop.queryComponent().grabFocus(), 'cannot focus Desktop layout' # GTK combo focus belongs to its internal button, which is not # exposed in the combo's AT-SPI children. Check the reader's # semantic focus events rather than the outer widget's state. for controlName, expectedRole in ( ('Time format', 'combo box'), ('Date format', 'combo box'), ('Progress bar output', 'combo box'), ('Report background progress bars', 'check box'), ): tabOffset = len(log.read_text()) subprocess.run(['xdotool', 'key', 'Tab'], check=True) wait_for(lambda: any( record.get('type') == 'object:state-changed:focused' and record.get('role') == expectedRole for record in (json.loads(line) for line in log.read_text()[tabOffset:].splitlines()) ), 'Tab did not reach ' + controlName) set_active_window(int(preferences)) subprocess.run(['xdotool', 'windowfocus', preferences, 'key', 'Escape'], check=True) wait_for(lambda: preferences_window() is None, 'Escape did not close preferences') set_active_window(int(window)) subprocess.run(['xdotool', 'windowfocus', window], check=True) print('PASS: Insert+X and desktop Insert+Return passed through; KP Enter routed Where Am I with both NumLock states; repeated Insert+Space routed preferences once') object_commands = [ ('Up', 'parent'), ('Down', 'first-child'), ('Left', 'previous-sibling'), ('Right', 'next-sibling'), ] for arrow, direction in object_commands: before_arrow = events.read_text().count(f', {arrow})') before_insert = events.read_text().count('keysym 0xff63, Insert)') before_command = count_records( log, '"message":"object review command received"', f'"direction":"{direction}"', ) subprocess.run([ 'xdotool', 'keydown', 'Insert', 'keydown', 'Control_L', 'key', arrow, 'keyup', 'Control_L', 'keyup', 'Insert', ], check=True) wait_for( lambda direction=direction, before_command=before_command: count_records( log, '"message":"object review command received"', f'"direction":"{direction}"', ) == before_command + 1, f'Insert+Control+{arrow} did not route {direction}', ) time.sleep(0.05) assert events.read_text().count(f', {arrow})') == before_arrow, f'{arrow} object navigation leaked to application' assert events.read_text().count('keysym 0xff63, Insert)') == before_insert, f'Insert+Control+{arrow} modifier leaked to application' before_up = events.read_text().count(', Up)') before_insert = events.read_text().count('keysym 0xff63, Insert)') before_parent = count_records( log, '"message":"object review command received"', '"direction":"parent"', ) subprocess.run([ 'xdotool', 'keydown', 'Control_L', 'keydown', 'Insert', 'key', 'Up', 'keyup', 'Insert', 'keyup', 'Control_L', ], check=True) wait_for( lambda: count_records( log, '"message":"object review command received"', '"direction":"parent"', ) == before_parent + 1, 'Control-first Insert+Control+Up did not route parent', ) time.sleep(0.05) assert events.read_text().count(', Up)') == before_up, 'Control-first object navigation leaked Up to application' assert events.read_text().count('keysym 0xff63, Insert)') == before_insert, 'Control-first object navigation leaked Insert to application' assert app.poll() is None, 'object navigation command stopped Cthulhu' print('PASS: Cthulhu+Control+Arrow consumed with balanced Insert-first and Control-first chords') for arrow, command in [('Left', 'select-previous-speech-setting'), ('Right', 'select-next-speech-setting'), ('Up', 'increase-speech-setting'), ('Down', 'decrease-speech-setting')]: before_arrow = events.read_text().count(f', {arrow})') before_command = count_records(log, '"message":"speech setting command received"', f'"command":"{command}"') subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', arrow, 'keyup', 'Insert', 'keyup', arrow], check=True) wait_for(lambda: count_records(log, '"message":"speech setting command received"', f'"command":"{command}"') == before_command + 1, 'speech shortcut did not dispatch: ' + command) time.sleep(0.05) assert events.read_text().count(f', {arrow})') == before_arrow, 'speech shortcut leaked arrow' print('PASS: Cthulhu+Arrow speech commands consumed with modifier released first') check_speech_mode_shortcut(log, events, 'Insert') print('PASS: Cthulhu+S consumed once with repeated keydown and both release orders') review_keys = [ ('KP_Home', 'u', 'review-previous-line'), ('KP_Up', 'i', 'review-current-line'), ('KP_Page_Up', 'o', 'review-next-line'), ('KP_Left', 'j', 'review-previous-word'), ('KP_Begin', 'k', 'review-current-word'), ('KP_Right', 'l', 'review-next-word'), ('KP_End', 'm', 'review-previous-character'), ('KP_Down', 'comma', 'review-current-character'), ('KP_Page_Down', 'period', 'review-next-character'), ] def flush_probe_events(): before_f12 = events.read_text().count(', F12)') subprocess.run(['xdotool', 'key', 'F12'], check=True) wait_for(lambda: events.read_text().count(', F12)') >= before_f12 + 2, 'ordinary key did not reach probe after review') for desktop_key, _, command in review_keys: before_key = events.read_text().count(', ' + desktop_key + ')') before_command = count_records(log, '"message":"flat review command received"', '"command":"' + command + '"') subprocess.run(['xdotool', 'key', desktop_key], check=True) wait_for(lambda: count_records(log, '"message":"flat review command received"', '"command":"' + command + '"') == before_command + 1, 'desktop review command missing: ' + command) flush_probe_events() assert events.read_text().count(', ' + desktop_key + ')') == before_key, \ 'desktop review key leaked: ' + desktop_key for desktop_key, command in [('KP_Home', 'review-window-start'), ('KP_Page_Up', 'review-window-end')]: before_key = events.read_text().count(', ' + desktop_key + ')') for modifier_first in (False, True): before_command = count_records(log, '"message":"flat review command received"', '"command":"' + command + '"') releases = ['Insert', desktop_key] if modifier_first else [desktop_key, 'Insert'] subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', desktop_key, 'keyup', releases[0], 'keyup', releases[1]], check=True) wait_for(lambda: count_records(log, '"message":"flat review command received"', '"command":"' + command + '"') == before_command + 1, 'desktop window review command missing: ' + command) flush_probe_events() assert events.read_text().count(', ' + desktop_key + ')') == before_key, \ 'desktop window review key leaked: ' + desktop_key before_review = count_records(log, '"message":"flat review command received"') subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) before_numeric = events.read_text().count(', KP_7)') numeric_offset = len(events.read_text()) press_physical_key(0xff95) # physical keypad 7/Home, with NumLock on try: wait_for(lambda: events.read_text().count(', KP_7)') == before_numeric + 2, 'NumLock numeric keypad was consumed by review') except RuntimeError: print(events.read_text()[numeric_offset:], file=sys.stderr) print('review records before/after:', before_review, count_records(log, '"message":"flat review command received"'), file=sys.stderr) raise assert count_records(log, '"message":"flat review command received"') == before_review subprocess.run(['xdotool', 'key', 'Num_Lock'], check=True) print('PASS: all nine desktop flat-review keys plus window start/end consumed; NumLock numeric typing preserved') subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'space', 'keyup', 'Insert'], check=True) preferences = wait_for(preferences_window, 'preferences did not reopen') set_active_window(int(preferences)) subprocess.run(['xdotool', 'windowfocus', preferences], check=True) subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q'], check=True) time.sleep(0.1) assert app.poll() is None, 'quit occurred before modifier release' subprocess.run(['xdotool', 'keyup', 'Insert'], check=True) assert app.wait(timeout=10) == 0, log.read_text() wait_for(lambda: preferences_window() is None, 'quit left preferences running') assert 'quit command received' in log.read_text(), log.read_text() time.sleep(0.1) assert events.read_text().count('keysym 0x71, q)') == before, 'command q leaked to application' subprocess.run(['xdotool', 'windowfocus', window], check=True) subprocess.run(['xdotool', 'key', 'q', 'Insert'], check=True) wait_for(lambda: events.read_text().count('keysym 0x71, q)') == before + 2, 'q grab remained after quit') assert 'keysym 0xff63, Insert)' in events.read_text(), 'Insert grab remained after quit' print('PASS: ordinary typing; Insert+Q consumed; release-safe quit; grabs removed') for modifier, lock, modifierFirst in [('Insert','Num_Lock',True), ('Insert','Caps_Lock',False), ('KP_Insert',None,True)]: if lock: subprocess.run(['xdotool','key',lock],check=True) app = subprocess.Popen([str(binary),'--config',str(config),'--debug-file',str(root / 'next.log')],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) processes.append(app) nextLog = root / 'next.log' wait_for(lambda: nextLog.exists() and 'listening for focus events' in nextLog.read_text(), 'restart did not become ready') # Give the startup loop a chance to enter native event dispatch. time.sleep(0.05) beforeQ = events.read_text().count('keycode 24 ') subprocess.run(['xdotool','keydown',modifier,'keydown','q'],check=True) if modifierFirst: subprocess.run(['xdotool','keyup',modifier,'keyup','q'],check=True) else: subprocess.run(['xdotool','keyup','q','keyup',modifier],check=True) assert app.wait(timeout=5) == 0, nextLog.read_text() time.sleep(0.05) assert events.read_text().count('keycode 24 ') == beforeQ, ('q leaked with lock keys or alternate release order', modifier, lock, modifierFirst, events.read_text()[-1800:]) if lock: subprocess.run(['xdotool','key',lock],check=True) nextLog.unlink() print('PASS: NumLock, CapsLock, keypad Insert, alternate release order, repeated startup/shutdown') config.write_text('[general]\nenableSpeech = false\nenableSound = false\nkeyboardLayout = 2\n') laptop_log = root / 'laptop.log' app = subprocess.Popen([str(binary), '--config', str(config), '--debug-file', str(laptop_log)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) processes.append(app) wait_for(lambda: laptop_log.exists() and 'listening for focus events' in laptop_log.read_text(), 'laptop layout did not start') check_speech_mode_shortcut(laptop_log, events, 'Insert') for _, laptop_key, command in review_keys: before_key = events.read_text().count(', ' + laptop_key + ')') before_insert = events.read_text().count(', Insert)') for modifier_first in (False, True): expected_command = command if modifier_first: expected_command = { 'review-current-word': 'review-spell-word', 'review-current-character': 'review-phonetic-character', }.get(command, command) before_command = count_records(laptop_log, '"message":"flat review command received"', '"command":"' + expected_command + '"') releases = ['Insert', laptop_key] if modifier_first else [laptop_key, 'Insert'] subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', laptop_key, 'keyup', releases[0], 'keyup', releases[1]], check=True) wait_for(lambda: count_records(laptop_log, '"message":"flat review command received"', '"command":"' + expected_command + '"') == before_command + 1, 'laptop review command missing: ' + expected_command) flush_probe_events() assert events.read_text().count(', ' + laptop_key + ')') == before_key, \ 'laptop review key leaked: ' + laptop_key assert events.read_text().count(', Insert)') == before_insert, 'laptop Insert leaked' for laptop_key, command in [('u', 'review-window-start'), ('o', 'review-window-end')]: for modifier_first in (False, True): before_key = events.read_text().count(', ' + laptop_key + ')') before_insert = events.read_text().count(', Insert)') before_command = count_records(laptop_log, '"message":"flat review command received"', '"command":"' + command + '"') subprocess.run(['xdotool', 'keydown', 'Insert', 'keydown', 'Control_L', 'keydown', laptop_key], check=True) wait_for(lambda: count_records(laptop_log, '"message":"flat review command received"', '"command":"' + command + '"') == before_command + 1, 'laptop window boundary command did not dispatch on press: ' + command) releases = ['Insert', 'Control_L', laptop_key] if modifier_first else [laptop_key, 'Control_L', 'Insert'] subprocess.run(['xdotool'] + [part for key in releases for part in ('keyup', key)], check=True) flush_probe_events() assert events.read_text().count(', ' + laptop_key + ')') == before_key, 'laptop boundary key leaked' assert events.read_text().count(', Insert)') == before_insert, 'laptop boundary Insert leaked' before_keypad = events.read_text().count(', KP_Up)') subprocess.run(['xdotool', 'key', 'KP_Up'], check=True) wait_for(lambda: events.read_text().count(', KP_Up)') == before_keypad + 2, 'laptop layout grabbed desktop review keys') subprocess.run(['xdotool', 'keydown', 'Insert', 'key', 'q', 'keyup', 'Insert'], check=True) assert app.wait(timeout=5) == 0, laptop_log.read_text() print('PASS: all nine laptop flat-review keys plus window start/end; both release orders; desktop keypad pass-through; shutdown') finally: for process in reversed(processes): if process.poll() is None: process.terminate() try: process.wait(timeout=3) except subprocess.TimeoutExpired: process.kill() process.wait() def main(): if len(sys.argv) == 3 and sys.argv[1] == '--inside': run_inside(sys.argv[2]) return with tempfile.TemporaryDirectory(prefix='cthulhu-x11-') as directory: readFd, writeFd = os.pipe() server = subprocess.Popen(['Xvfb', '-displayfd', str(writeFd), '-screen', '0', '800x600x24', '-nolisten', 'tcp'], pass_fds=(writeFd,), stderr=subprocess.DEVNULL) os.close(writeFd) try: with os.fdopen(readFd) as reader: display = reader.readline().strip() if not display: raise RuntimeError('Xvfb failed to start') environment = dict(os.environ, DISPLAY=':' + display) environment.pop('DBUS_SESSION_BUS_ADDRESS', None) environment.pop('AT_SPI_BUS_ADDRESS', None) environment.pop('WAYLAND_DISPLAY', None) environment['XDG_SESSION_TYPE'] = 'x11' subprocess.run(['dbus-run-session', '--', sys.executable, str(Path(__file__).resolve()), '--inside', directory], env=environment, check=True, timeout=60) finally: server.terminate() server.wait(timeout=3) if __name__ == '__main__': main()