Improve base guidance and qualify bounded aircraft route and hidden test lifecycle
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only X11 ownership observer, executed INSIDE an owned headless Gamescope.
|
||||
No input/focus API, no engine launch of its own, no desktop DISPLAY fallback.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
PROFILE_ID = {'kind': 'erri-ordinary-game-disposable-profile', 'version': 1}
|
||||
MAX_WINDOWS = 1024
|
||||
|
||||
|
||||
def display_number(value):
|
||||
match = re.fullmatch(r'(?:unix)?:([0-9]{1,5})(?:\.([0-9]{1,3}))?', value or '')
|
||||
if not match:
|
||||
raise ValueError('DISPLAY must be explicit local Unix :N[.screen]')
|
||||
return int(match[1])
|
||||
|
||||
|
||||
def process(pid, proc_root=Path('/proc')):
|
||||
p = proc_root / str(pid)
|
||||
fields = (p/'stat').read_text().rsplit(')', 1)[1].split()
|
||||
return {'pid': int(pid), 'ppid': int(fields[1]), 'start': int(fields[19]),
|
||||
'uid': p.stat().st_uid, 'exe': os.readlink(p/'exe'),
|
||||
'args': [v.decode('utf-8', 'strict') for v in (p/'cmdline').read_bytes().split(b'\0') if v]}
|
||||
|
||||
|
||||
def environment(pid, proc_root=Path('/proc')):
|
||||
# Only allowlisted values leave this function; never serialize the environment.
|
||||
values = {}
|
||||
for value in (proc_root/str(pid)/'environ').read_bytes().split(b'\0'):
|
||||
key, _, content = value.partition(b'=')
|
||||
if key in (b'DISPLAY', b'WINEPREFIX', b'PULSE_PROP'):
|
||||
values[key.decode()] = content.decode('utf-8', 'strict')
|
||||
return values
|
||||
|
||||
|
||||
def prefix_matches(value, expected):
|
||||
"""Proton's pfx/ spelling only; do not normalize aliases or path components."""
|
||||
return isinstance(value, str) and value.rstrip('/') == str(expected)
|
||||
|
||||
|
||||
def identity(p):
|
||||
return {key: p[key] for key in ('pid', 'start', 'uid')}
|
||||
|
||||
|
||||
def headless_args(args):
|
||||
options = args[1:args.index('--')] if '--' in args else []
|
||||
return (options.count('--backend') == 1 and
|
||||
options[options.index('--backend') + 1:options.index('--backend') + 2] == ['headless'] and
|
||||
not any(value.startswith('--backend=') for value in options))
|
||||
|
||||
|
||||
def ancestor(pid, read=process):
|
||||
seen = set()
|
||||
for _ in range(64):
|
||||
if pid < 2 or pid in seen:
|
||||
break
|
||||
seen.add(pid)
|
||||
p = read(pid)
|
||||
if p['uid'] != os.getuid():
|
||||
raise ValueError('Foreign-user ancestor')
|
||||
if Path(p['exe']).name == 'gamescope':
|
||||
if not headless_args(p['args']):
|
||||
raise ValueError('Gamescope ancestor is not explicitly headless')
|
||||
return p
|
||||
pid = p['ppid']
|
||||
raise ValueError('No owned headless Gamescope ancestor')
|
||||
|
||||
|
||||
def descendant(pid, parent, read=process):
|
||||
for _ in range(64):
|
||||
if pid == parent:
|
||||
return True
|
||||
if pid < 2:
|
||||
break
|
||||
pid = read(pid)['ppid']
|
||||
return False
|
||||
|
||||
|
||||
def validate_display(display, host_display):
|
||||
number = display_number(display)
|
||||
# Host DISPLAY can be remote; never open it. Also reject localhost aliases.
|
||||
host_match = re.fullmatch(r'(?:unix|localhost)?:(\d+)(?:\.\d+)?', host_display or '')
|
||||
if display == host_display or (host_match and int(host_match[1]) == number):
|
||||
raise ValueError('Nested DISPLAY is the supplied host display')
|
||||
return number
|
||||
|
||||
|
||||
def owned_sockets(number, pid, proc_root=Path('/proc')):
|
||||
"""Prove every local Xlib connection address for this number is owned, before XOpenDisplay."""
|
||||
paths = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
|
||||
sockets = []
|
||||
for line in (proc_root/'net/unix').read_text().splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) < 8 or fields[7] not in paths:
|
||||
continue
|
||||
if fields[4] != '0001' or fields[5] != '01' or not int(fields[3], 16) & 0x10000:
|
||||
continue
|
||||
sockets.append({'path':fields[7], 'inode':int(fields[6])})
|
||||
if not sockets or len({item['path'] for item in sockets}) != len(sockets):
|
||||
raise ValueError('No unique live local X11 listening socket')
|
||||
fds = set()
|
||||
for fd in (proc_root/str(pid)/'fd').iterdir():
|
||||
try:
|
||||
target = os.readlink(fd)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
match = re.fullmatch(r'socket:\[(\d+)\]', target)
|
||||
if match:
|
||||
fds.add(int(match[1]))
|
||||
if any(item['inode'] not in fds for item in sockets):
|
||||
raise ValueError('Local display socket is not held by owned Xwayland')
|
||||
return sorted(sockets, key=lambda item:item['path'])
|
||||
|
||||
|
||||
def candidate_relationship(p, scope, number, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
|
||||
"""Independent, sanitized process/socket checks for reparented Xwayland."""
|
||||
expected = os.environ.get('PULSE_PROP', '')
|
||||
valid_nonce = bool(re.fullmatch(r'application.id=org\.escapistsrereintroduced\.game\.[0-9a-f]{32}', expected))
|
||||
result = {'start_not_older_than_gamescope':p['start'] >= scope['start'],
|
||||
'wrapper_run_nonce_valid':valid_nonce,
|
||||
'xwayland_prefix_matches':None, 'gamescope_prefix_matches':None,
|
||||
'xwayland_run_nonce_matches':None, 'gamescope_run_nonce_matches':None,
|
||||
'xwayland_holds_all_display_listeners':False, 'gamescope_holds_all_display_listeners':False,
|
||||
'same_listener_inodes':False, 'listener_inodes':[], 'process_identities_stable':False}
|
||||
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
|
||||
try:
|
||||
env = env_read(pid)
|
||||
result[role+'_prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
|
||||
result[role+'_run_nonce_matches'] = valid_nonce and env.get('PULSE_PROP') == expected
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
pass
|
||||
listeners = {}
|
||||
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
|
||||
try:
|
||||
listeners[role] = owned_sockets(number,pid,proc_root)
|
||||
result[role+'_holds_all_display_listeners'] = True
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if 'xwayland' in listeners:
|
||||
result['listener_inodes'] = [item['inode'] for item in listeners['xwayland']]
|
||||
result['same_listener_inodes'] = bool(listeners.get('xwayland')) and listeners.get('xwayland') == listeners.get('gamescope')
|
||||
try:
|
||||
result['process_identities_stable'] = identity(read(p['pid'])) == identity(p) and identity(read(scope['pid'])) == identity(scope)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
SHARED_CHECKS = (
|
||||
'start_not_older_than_gamescope', 'wrapper_run_nonce_valid',
|
||||
'xwayland_prefix_matches', 'gamescope_prefix_matches',
|
||||
'xwayland_run_nonce_matches', 'gamescope_run_nonce_matches',
|
||||
'xwayland_holds_all_display_listeners', 'gamescope_holds_all_display_listeners',
|
||||
'same_listener_inodes', 'process_identities_stable',
|
||||
)
|
||||
|
||||
|
||||
def shared_relationship_valid(details):
|
||||
if not isinstance(details, dict) or not all(details.get(key) is True for key in SHARED_CHECKS):
|
||||
return False
|
||||
inodes = details.get('listener_inodes')
|
||||
return (isinstance(inodes, list) and 1 <= len(inodes) <= 2 and
|
||||
all(type(inode) is int and inode > 0 for inode in inodes) and len(set(inodes)) == len(inodes))
|
||||
|
||||
|
||||
def binding(host_display, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
|
||||
display = os.environ.get('DISPLAY', '')
|
||||
number = validate_display(display, host_display)
|
||||
if not prefix_matches(os.environ.get('WINEPREFIX'),prefix):
|
||||
raise ValueError('Wrapper prefix does not match supplied private prefix')
|
||||
scope = ancestor(os.getppid(), read)
|
||||
matches = []
|
||||
candidates = []
|
||||
candidate_count = 0
|
||||
unreadable = 0
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
p = read(int(entry.name))
|
||||
if p['uid'] != os.getuid():
|
||||
continue
|
||||
basename = Path(p['exe']).name
|
||||
argv_name = Path(p['args'][0]).name if p['args'] else ''
|
||||
# Broader names are DIAGNOSTICS ONLY. The authorization check below
|
||||
# still requires the exact native executable name and an owned relationship.
|
||||
if 'xwayland' not in basename.lower() and 'xwayland' not in argv_name.lower():
|
||||
continue
|
||||
candidate_count += 1
|
||||
displays = [v for v in p['args'][1:] if re.fullmatch(r':\d+(?:\.\d+)?', v)]
|
||||
portal = any(v == '-enable-ei-portal' or v.startswith('-enable-ei-portal=') for v in p['args'])
|
||||
try:
|
||||
nested = descendant(p['pid'], scope['pid'], read)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
nested = None
|
||||
relationship = None
|
||||
if len(displays) == 1 and display_number(displays[0]) == number:
|
||||
relationship = candidate_relationship(p,scope,number,prefix,read,env_read,proc_root)
|
||||
if len(candidates) < 16:
|
||||
candidate = {**identity(p), 'ppid':p['ppid'],
|
||||
'executable_basename':basename[:96],
|
||||
'display_args':displays[:4], 'display_arg_count':len(displays),
|
||||
'portal_enabled':portal, 'descendant':nested,
|
||||
'exact_executable_name':basename == 'Xwayland'}
|
||||
if relationship is not None:
|
||||
candidate['selected_display_relationship'] = relationship
|
||||
candidates.append(candidate)
|
||||
if basename != 'Xwayland':
|
||||
continue
|
||||
if len(displays) != 1 or display_number(displays[0]) != number:
|
||||
continue
|
||||
if portal:
|
||||
raise ValueError('Nested Xwayland has input-emulation portal enabled')
|
||||
if nested is True:
|
||||
matches.append((p, {'mode':'descendant', 'descendant':True}))
|
||||
elif shared_relationship_valid(relationship):
|
||||
matches.append((p, {'mode':'shared-listeners', 'checks':relationship}))
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
unreadable += 1
|
||||
continue
|
||||
if len(matches) != 1:
|
||||
diagnostic = {'display':display, 'gamescope':{**identity(scope),'ppid':scope['ppid']},
|
||||
'matching_candidates':len(matches), 'same_user_xwayland_candidates':candidates,
|
||||
'candidate_count':candidate_count, 'candidate_limit':16,
|
||||
'unreadable_process_count':unreadable,
|
||||
'read_only':True, 'display_opened':False, 'input_sent':False}
|
||||
raise ValueError('Cannot identify exactly one owned nested Xwayland; preflight=' +
|
||||
json.dumps(diagnostic,sort_keys=True,separators=(',',':')))
|
||||
selected, ownership = matches[0]
|
||||
sockets = owned_sockets(number, selected['pid'], proc_root)
|
||||
for pinned in (selected, scope):
|
||||
current = read(pinned['pid'])
|
||||
if identity(current) != identity(pinned) or current['exe'] != pinned['exe'] or current['args'] != pinned['args']:
|
||||
raise ValueError('Native process changed during socket ownership check')
|
||||
if ownership['mode'] == 'shared-listeners':
|
||||
final_checks = candidate_relationship(selected,scope,number,prefix,read,env_read,proc_root)
|
||||
if (not shared_relationship_valid(final_checks) or final_checks != ownership['checks'] or
|
||||
final_checks['listener_inodes'] != [item['inode'] for item in sockets]):
|
||||
raise ValueError('Shared listener ownership changed during binding')
|
||||
elif not descendant(selected['pid'],scope['pid'],read):
|
||||
raise ValueError('Native descendant ownership changed during binding')
|
||||
return {'display': display, 'gamescope': identity(scope), 'xwayland': identity(selected),
|
||||
'sockets': sockets, 'relationship': ownership}
|
||||
|
||||
|
||||
def executable_argument(args, executable):
|
||||
# Match an exact full PE path, not a basename/title/comm guess.
|
||||
accepted = {str(executable), 'Z:'+str(executable), 'Z:'+str(executable).replace('/', '\\')}
|
||||
return bool(args) and args[0] in accepted
|
||||
|
||||
|
||||
def owned_window(window, bound, prefix, executable, read=process, env_read=environment):
|
||||
pid = window.get('pid')
|
||||
if type(pid) is not int or pid <= 0 or pid != window.get('server_pid'):
|
||||
return None
|
||||
try:
|
||||
before = read(pid)
|
||||
if before['uid'] != os.getuid() or not executable_argument(before['args'], executable):
|
||||
return None
|
||||
env = env_read(pid)
|
||||
if not prefix_matches(env.get('WINEPREFIX'),prefix) or env.get('DISPLAY') != bound['display']:
|
||||
return None
|
||||
if identity(read(pid)) != identity(before):
|
||||
return None
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return None
|
||||
return {**window, 'process': identity(before)}
|
||||
|
||||
|
||||
def window_process_diagnostic(pid, bound, prefix, executable, read=process, env_read=environment):
|
||||
result = {'readable':False, 'same_user':None, 'game_executable_match':False,
|
||||
'argv0_class':'unreadable', 'argv0_case_insensitive_exact':False,
|
||||
'prefix_matches':None, 'display_matches':None, 'process_stable':False}
|
||||
if type(pid) is not int or pid <= 0:
|
||||
result['argv0_class'] = 'invalid-pid'
|
||||
return result
|
||||
try:
|
||||
before = read(pid)
|
||||
result['readable'] = True
|
||||
result['same_user'] = before['uid'] == os.getuid()
|
||||
argv0 = before['args'][0] if before['args'] else ''
|
||||
exact = executable_argument(before['args'],executable)
|
||||
result['game_executable_match'] = exact
|
||||
if exact:
|
||||
result['argv0_class'] = 'exact-unix-game' if argv0 == str(executable) else 'exact-wine-z-game'
|
||||
elif not argv0:
|
||||
result['argv0_class'] = 'empty'
|
||||
elif re.split(r'[\\/]',argv0)[-1].lower() == executable.name.lower():
|
||||
result['argv0_class'] = 'game-basename-noncanonical-path'
|
||||
elif argv0.lower().endswith('.exe'):
|
||||
result['argv0_class'] = 'other-windows-executable'
|
||||
else:
|
||||
result['argv0_class'] = 'other'
|
||||
normalized = argv0.replace('\\','/').lower()
|
||||
result['argv0_case_insensitive_exact'] = normalized in {str(executable).lower(),('Z:'+str(executable)).lower()}
|
||||
env = env_read(pid)
|
||||
result['prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
|
||||
result['display_matches'] = env.get('DISPLAY') == bound['display']
|
||||
result['process_stable'] = identity(read(pid)) == identity(before)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def window_diagnostic(window, bound, prefix, executable, read=process, env_read=environment):
|
||||
pid, server = window.get('pid'), window.get('server_pid')
|
||||
property_process = window_process_diagnostic(pid,bound,prefix,executable,read,env_read)
|
||||
server_process = property_process if pid == server else window_process_diagnostic(server,bound,prefix,executable,read,env_read)
|
||||
if type(pid) is not int or pid <= 0:
|
||||
stage = 'invalid-property-pid'
|
||||
elif type(server) is not int or server <= 0:
|
||||
stage = 'server-pid-unavailable'
|
||||
elif pid != server:
|
||||
stage = 'property-server-pid-disagreement'
|
||||
elif not property_process['readable']:
|
||||
stage = 'native-process-unreadable'
|
||||
elif property_process['same_user'] is not True:
|
||||
stage = 'foreign-user'
|
||||
elif not property_process['game_executable_match']:
|
||||
stage = 'game-executable-mismatch'
|
||||
elif property_process['prefix_matches'] is not True:
|
||||
stage = 'private-prefix-unverified'
|
||||
elif property_process['display_matches'] is not True:
|
||||
stage = 'private-display-unverified'
|
||||
elif not property_process['process_stable']:
|
||||
stage = 'native-process-changed'
|
||||
else:
|
||||
stage = 'checks-passed'
|
||||
return {'window':window.get('window'), 'pid':pid, 'server_pid':server,
|
||||
'rejection_stage':stage, 'property_process':property_process, 'server_process':server_process,
|
||||
'xres_query':window.get('xres_query')}
|
||||
|
||||
|
||||
class WindowAttributes(C.Structure):
|
||||
# Exact installed Xlib.h layout; Bool is int and XIDs are unsigned long.
|
||||
_fields_ = [('x',C.c_int),('y',C.c_int),('width',C.c_int),('height',C.c_int),
|
||||
('border_width',C.c_int),('depth',C.c_int),('visual',C.c_void_p),
|
||||
('root',C.c_ulong),('window_class',C.c_int),('bit_gravity',C.c_int),
|
||||
('win_gravity',C.c_int),('backing_store',C.c_int),('backing_planes',C.c_ulong),
|
||||
('backing_pixel',C.c_ulong),('save_under',C.c_int),('colormap',C.c_ulong),
|
||||
('map_installed',C.c_int),('map_state',C.c_int),('all_event_masks',C.c_long),
|
||||
('your_event_mask',C.c_long),('do_not_propagate_mask',C.c_long),
|
||||
('override_redirect',C.c_int),('screen',C.c_void_p)]
|
||||
|
||||
|
||||
def mapped_geometry(query, window, root):
|
||||
attrs = query.attributes(window)
|
||||
root_attrs = query.attributes(root)
|
||||
for item in (attrs,root_attrs):
|
||||
if (not isinstance(item,dict) or any(type(item.get(key)) is not int for key in
|
||||
('root','window_class','map_state','width','height')) or
|
||||
item['root'] != root or item['window_class'] != 1 or item['map_state'] != 2 or
|
||||
not 0 < item['width'] <= 65535 or not 0 < item['height'] <= 65535):
|
||||
raise ValueError('unmapped-or-invalid-window-attributes')
|
||||
x,y = query.translate(window,root)
|
||||
if type(x) is not int or type(y) is not int:
|
||||
raise ValueError('invalid-window-root-coordinates')
|
||||
clipped = [max(0,x),max(0,y),min(root_attrs['width'],x+attrs['width']),min(root_attrs['height'],y+attrs['height'])]
|
||||
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
|
||||
raise ValueError('window-outside-private-root')
|
||||
return {'window':window,'root':root,'map_state':attrs['map_state'],'window_class':attrs['window_class'],
|
||||
'x':x,'y':y,'width':attrs['width'],'height':attrs['height'],
|
||||
'root_width':root_attrs['width'],'root_height':root_attrs['height'],'clipped_bounds':clipped}
|
||||
|
||||
|
||||
def input_target(query, owner):
|
||||
"""Point-in-time read-only mapped/focus evidence; never acquires focus."""
|
||||
result = {'window':owner.get('window'),'verified':False,'reason':'not-observed'}
|
||||
try:
|
||||
if any(type(owner.get(key)) is not int or owner[key] <= 1 for key in ('window','pid')):
|
||||
raise ValueError('invalid-owned-window-identity')
|
||||
root = query.root()
|
||||
if type(root) is not int or root <= 1:
|
||||
raise ValueError('invalid-private-root')
|
||||
geometry = mapped_geometry(query,owner['window'],root)
|
||||
result['geometry'] = geometry
|
||||
focused,revert = query.focus()
|
||||
if type(focused) is not int or focused <= 1 or type(revert) is not int or revert not in (0,1,2):
|
||||
raise ValueError('no-concrete-existing-focus')
|
||||
result['focus_window'] = focused
|
||||
result['focus_revert_to'] = revert
|
||||
chain = []; current = focused
|
||||
clipped = list(geometry['clipped_bounds'])
|
||||
for _ in range(32):
|
||||
if current in chain or current == root or current <= 1:
|
||||
raise ValueError('focus-outside-owned-window-tree')
|
||||
client_pid = query.server_pid(current)
|
||||
if type(client_pid) is not int or client_pid != owner['pid']:
|
||||
raise ValueError('focus-client-not-owned-game')
|
||||
child_geometry = mapped_geometry(query,current,root)
|
||||
bounds = child_geometry['clipped_bounds']
|
||||
clipped = [max(clipped[0],bounds[0]),max(clipped[1],bounds[1]),min(clipped[2],bounds[2]),min(clipped[3],bounds[3])]
|
||||
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
|
||||
raise ValueError('focus-has-no-visible-owned-intersection')
|
||||
chain.append(current)
|
||||
if current == owner['window']:
|
||||
break
|
||||
tree_root,parent,_ = query.tree(current)
|
||||
if tree_root != root or type(parent) is not int:
|
||||
raise ValueError('focus-tree-root-mismatch')
|
||||
current = parent
|
||||
else:
|
||||
raise ValueError('focus-ancestry-budget-exceeded')
|
||||
if query.focus() != (focused,revert) or mapped_geometry(query,owner['window'],root) != geometry:
|
||||
raise ValueError('focus-or-geometry-changed-during-observation')
|
||||
result.update(verified=True,reason='owned-mapped-existing-focus',focus_chain=chain,
|
||||
focused_clipped_bounds=clipped)
|
||||
except (OSError,ValueError,TypeError,KeyError) as error:
|
||||
result['reason'] = str(error)[:160]
|
||||
return result
|
||||
|
||||
|
||||
class X11:
|
||||
"""Explicit display connection; all exported methods are read-only queries."""
|
||||
class Spec(C.Structure):
|
||||
_fields_ = [('client', C.c_ulong), ('mask', C.c_uint)]
|
||||
|
||||
class Client(C.Structure):
|
||||
pass
|
||||
|
||||
def __init__(self, display):
|
||||
self.x = C.CDLL('libX11.so.6')
|
||||
self.r = C.CDLL('libXRes.so.1')
|
||||
self.d = None
|
||||
self.query_budget = MAX_WINDOWS
|
||||
signatures = {
|
||||
'XOpenDisplay': (C.c_void_p, [C.c_char_p]),
|
||||
'XCloseDisplay': (C.c_int, [C.c_void_p]),
|
||||
'XDefaultRootWindow': (C.c_ulong, [C.c_void_p]),
|
||||
'XGetWindowAttributes': (C.c_int,[C.c_void_p,C.c_ulong,C.POINTER(WindowAttributes)]),
|
||||
'XGetInputFocus': (C.c_int,[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)]),
|
||||
'XTranslateCoordinates': (C.c_int,[C.c_void_p,C.c_ulong,C.c_ulong,C.c_int,C.c_int,C.POINTER(C.c_int),C.POINTER(C.c_int),C.POINTER(C.c_ulong)]),
|
||||
'XInternAtom': (C.c_ulong, [C.c_void_p, C.c_char_p, C.c_int]),
|
||||
'XFree': (C.c_int, [C.c_void_p]),
|
||||
'XQueryTree': (C.c_int, [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.POINTER(C.c_ulong)), C.POINTER(C.c_uint)]),
|
||||
'XGetWindowProperty': (C.c_int, [C.c_void_p, C.c_ulong, C.c_ulong, C.c_long, C.c_long, C.c_int, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_int), C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.c_void_p)]),
|
||||
}
|
||||
for name, (ret, args) in signatures.items():
|
||||
method = getattr(self.x, name); method.restype = ret; method.argtypes = args
|
||||
self.r.XResQueryClientIds.argtypes = [C.c_void_p, C.c_long, C.POINTER(self.Spec), C.POINTER(C.c_long), C.POINTER(C.POINTER(self.Client))]
|
||||
self.r.XResQueryClientIds.restype = C.c_int
|
||||
self.r.XResGetClientPid.argtypes = [C.POINTER(self.Client)]
|
||||
self.r.XResGetClientPid.restype = C.c_int
|
||||
self.r.XResClientIdsDestroy.argtypes = [C.c_long, C.POINTER(self.Client)]
|
||||
self.r.XResClientIdsDestroy.restype = None
|
||||
self.d = self.x.XOpenDisplay(display.encode('ascii'))
|
||||
if not self.d:
|
||||
raise ValueError('Explicit nested XOpenDisplay failed')
|
||||
|
||||
def close(self):
|
||||
if self.d:
|
||||
self.x.XCloseDisplay(self.d); self.d = None
|
||||
|
||||
def prop(self, window, name):
|
||||
atom = self.x.XInternAtom(self.d, name.encode(), 1)
|
||||
if not atom:
|
||||
return None
|
||||
kind, fmt, count, after, data = C.c_ulong(), C.c_int(), C.c_ulong(), C.c_ulong(), C.c_void_p()
|
||||
status = self.x.XGetWindowProperty(self.d, window, atom, 0, 256, 0, 0,
|
||||
C.byref(kind), C.byref(fmt), C.byref(count), C.byref(after), C.byref(data))
|
||||
try:
|
||||
if status or after.value or not data.value:
|
||||
return None
|
||||
if name == '_NET_WM_PID':
|
||||
cardinal = self.x.XInternAtom(self.d, b'CARDINAL', 1)
|
||||
if fmt.value == 32 and count.value == 1 and kind.value == cardinal:
|
||||
return C.cast(data, C.POINTER(C.c_ulong))[0]
|
||||
return None
|
||||
if fmt.value == 8 and count.value <= 1024:
|
||||
return C.string_at(data, count.value).decode('utf-8', 'replace')[:256]
|
||||
return None
|
||||
finally:
|
||||
if data.value:
|
||||
self.x.XFree(data)
|
||||
|
||||
def server_pid(self, window):
|
||||
spec = self.Spec(window, 2) # XRES_CLIENT_ID_PID_MASK, installed XRes.h
|
||||
count, values = C.c_long(), C.POINTER(self.Client)()
|
||||
# This installed XRes API returns Success (0), unlike XQueryTree's Bool.
|
||||
status = self.r.XResQueryClientIds(self.d, 1, C.byref(spec), C.byref(count), C.byref(values))
|
||||
if not hasattr(self,'pid_diagnostics'):
|
||||
self.pid_diagnostics = {}
|
||||
diagnostic = {'status':int(status),'count':count.value,'mask':None}
|
||||
self.pid_diagnostics[window] = diagnostic
|
||||
if status != 0:
|
||||
return None
|
||||
try:
|
||||
if count.value == 1 and values:
|
||||
diagnostic['mask'] = values[0].spec.mask
|
||||
if count.value != 1 or not values or values[0].spec.mask != 2:
|
||||
return None
|
||||
pid = self.r.XResGetClientPid(values)
|
||||
return pid if pid > 0 else None
|
||||
finally:
|
||||
self.r.XResClientIdsDestroy(count, values)
|
||||
|
||||
def budget(self):
|
||||
self.query_budget -= 1
|
||||
if self.query_budget < 0:
|
||||
raise ValueError('native-read-query-budget-exceeded')
|
||||
|
||||
def root(self):
|
||||
return self.x.XDefaultRootWindow(self.d)
|
||||
|
||||
def attributes(self,window):
|
||||
self.budget()
|
||||
attrs = WindowAttributes()
|
||||
if not self.x.XGetWindowAttributes(self.d,window,C.byref(attrs)):
|
||||
raise ValueError('native-window-attributes-unavailable')
|
||||
return {key:getattr(attrs,key) for key in ('root','window_class','map_state','width','height')}
|
||||
|
||||
def focus(self):
|
||||
self.budget()
|
||||
window,revert = C.c_ulong(),C.c_int()
|
||||
if not self.x.XGetInputFocus(self.d,C.byref(window),C.byref(revert)):
|
||||
raise ValueError('native-input-focus-unavailable')
|
||||
return window.value,revert.value
|
||||
|
||||
def translate(self,window,root):
|
||||
self.budget()
|
||||
x,y,child = C.c_int(),C.c_int(),C.c_ulong()
|
||||
if not self.x.XTranslateCoordinates(self.d,window,root,0,0,C.byref(x),C.byref(y),C.byref(child)):
|
||||
raise ValueError('native-window-root-translation-unavailable')
|
||||
return x.value,y.value
|
||||
|
||||
def tree(self,window):
|
||||
self.budget()
|
||||
root,parent,children,count = C.c_ulong(),C.c_ulong(),C.POINTER(C.c_ulong)(),C.c_uint()
|
||||
if not self.x.XQueryTree(self.d,window,C.byref(root),C.byref(parent),C.byref(children),C.byref(count)):
|
||||
raise ValueError('Window tree changed during query')
|
||||
try:
|
||||
if count.value > MAX_WINDOWS:
|
||||
raise ValueError('Window tree exceeds bounded query budget')
|
||||
return root.value,parent.value,[children[i] for i in range(count.value)]
|
||||
finally:
|
||||
if children:
|
||||
self.x.XFree(children)
|
||||
|
||||
def windows(self):
|
||||
pending = [self.x.XDefaultRootWindow(self.d)]; seen = set(); found = []
|
||||
while pending:
|
||||
if len(seen) >= MAX_WINDOWS:
|
||||
raise ValueError('Window tree exceeds bounded query budget')
|
||||
window = pending.pop()
|
||||
if window in seen:
|
||||
raise ValueError('Unexpected repeated window tree entry')
|
||||
seen.add(window)
|
||||
pid = self.prop(window, '_NET_WM_PID')
|
||||
if pid is not None:
|
||||
server_pid = self.server_pid(window)
|
||||
found.append({'window': window, 'pid': pid, 'server_pid': server_pid,
|
||||
'xres_query':self.pid_diagnostics.get(window),
|
||||
'title': self.prop(window, '_NET_WM_NAME') or self.prop(window, 'WM_NAME') or ''})
|
||||
_,_,children = self.tree(window)
|
||||
if len(children) + len(pending) + len(seen) > MAX_WINDOWS:
|
||||
raise ValueError('Window tree exceeds bounded query budget')
|
||||
pending.extend(children)
|
||||
return found
|
||||
|
||||
|
||||
X11.Client._fields_ = [('spec', X11.Spec), ('length', C.c_long), ('value', C.c_void_p)]
|
||||
|
||||
|
||||
def observe(args, query_class=X11):
|
||||
before = binding(args.host_display, args.prefix)
|
||||
query = query_class(before['display'])
|
||||
try:
|
||||
windows = query.windows()
|
||||
owned = [match for window in windows if (match := owned_window(window,before,args.prefix,args.game_executable))]
|
||||
targets = [input_target(query,owner) for owner in owned[:16]]
|
||||
# Recheck the exact native process after geometry/focus reads. Never
|
||||
# promote a stale process identity into an input target.
|
||||
for target,owner in zip(targets,owned):
|
||||
current = owned_window(owner,before,args.prefix,args.game_executable)
|
||||
if current is None or current['process'] != owner['process']:
|
||||
target.update(verified=False,reason='game-process-changed-during-target-observation')
|
||||
finally:
|
||||
query.close()
|
||||
if binding(args.host_display, args.prefix) != before:
|
||||
raise ValueError('Display ownership changed during read-only query')
|
||||
diagnostics = [window_diagnostic(window,before,args.prefix,args.game_executable) for window in windows[:16]]
|
||||
valid_targets = [target for target in targets if target['verified']]
|
||||
# Nested owned windows can both contain focus. Prefer the exact focused
|
||||
# owned window; otherwise require exactly one unambiguous owned ancestor.
|
||||
exact_targets = [target for target in valid_targets if target.get('focus_window') == target['window']]
|
||||
chosen = exact_targets if exact_targets else valid_targets
|
||||
return {'binding':before,'owned_windows':owned,'owned_game_window_verified':bool(owned),
|
||||
'input_target_verified':len(chosen) == 1,'input_target':chosen[0] if len(chosen) == 1 else None,
|
||||
'input_target_observations':targets,'input_target_limit':16,
|
||||
'window_count':len(windows),'window_diagnostic_limit':16,'window_diagnostics':diagnostics,
|
||||
'unverified_window_count':len(windows)-len(owned)}
|
||||
|
||||
|
||||
def validate_paths(args):
|
||||
for path in (args.prefix, args.game_executable, args.profile):
|
||||
if not path.is_absolute() or path != path.resolve() or not path.exists():
|
||||
raise ValueError('Expected existing canonical runtime paths')
|
||||
if not args.prefix.is_dir() or not args.game_executable.is_file() or not args.profile.is_dir():
|
||||
raise ValueError('Invalid prefix, executable or profile type')
|
||||
if not args.profile.is_relative_to('/tmp') or args.profile == Path('/tmp'):
|
||||
raise ValueError('Report profile must be an owned private /tmp profile')
|
||||
marker = args.profile/'.erri-game-profile.json'
|
||||
if marker.is_symlink() or json.loads(marker.read_text()) != PROFILE_ID:
|
||||
raise ValueError('Profile is not marked as disposable ordinary-game profile')
|
||||
if (not args.report.is_absolute() or args.report.parent != args.profile or
|
||||
args.report.name in ('', '.', '..') or args.report.is_symlink() or (args.report.exists() and not args.query_worker)):
|
||||
raise ValueError('Report must be an exclusively new direct child of private profile')
|
||||
if args.profile.stat().st_uid != os.getuid() or not 1 <= args.observe_seconds <= 30:
|
||||
raise ValueError('Invalid report owner or observation bound')
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host-display', required=True)
|
||||
parser.add_argument('--prefix', type=Path, required=True)
|
||||
parser.add_argument('--game-executable', type=Path, required=True)
|
||||
parser.add_argument('--profile', type=Path, required=True)
|
||||
parser.add_argument('--report', type=Path, required=True)
|
||||
parser.add_argument('--observe-seconds', type=float, default=20)
|
||||
parser.add_argument('--query-worker', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('command', nargs=argparse.REMAINDER)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
validate_paths(args)
|
||||
bound = binding(args.host_display, args.prefix)
|
||||
if args.query_worker:
|
||||
print(json.dumps(observe(args)))
|
||||
return 0
|
||||
command = args.command[1:] if args.command[:1] == ['--'] else args.command
|
||||
if (len(command) < 3 or Path(command[0]).name != 'proton' or
|
||||
not Path(command[0]).is_absolute() or not Path(command[0]).is_file() or
|
||||
command[1] != 'run' or command[2] != str(args.game_executable)):
|
||||
raise ValueError('Wrapper command must contain the exact expected game executable')
|
||||
# A new report is a prerequisite for starting the requested child. No overwrite.
|
||||
fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
report = {'schema': 1, 'kind': 'headless-display-read-only', 'binding': bound,
|
||||
'owned_game_window_verified': False, 'input_target_verified': False, 'read_only': True, 'input_sent': False, 'release_qualified': False,
|
||||
'scope': 'Read-only private X11 process/window ownership; no input or gameplay proof',
|
||||
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'wrapper': identity(process(os.getpid())), 'command': command,
|
||||
'prefix': str(args.prefix), 'profile': str(args.profile),
|
||||
'game_executable': str(args.game_executable), 'report': str(args.report)}
|
||||
report.update(query_attempts=0,successful_queries=0,first_window_count=None,last_window_count=None,
|
||||
first_query_error=None,last_query_error=None,last_query_status='not-started')
|
||||
child = None
|
||||
previous = {}
|
||||
received = []
|
||||
def forward(signum, frame):
|
||||
received.append(signum)
|
||||
if child is not None and child.poll() is None:
|
||||
child.send_signal(signum)
|
||||
try:
|
||||
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
previous[signum] = signal.signal(signum, forward)
|
||||
child = subprocess.Popen(command) # inherit existing owned process group and streams
|
||||
for signum in received:
|
||||
if child.poll() is None:
|
||||
child.send_signal(signum)
|
||||
deadline = time.monotonic() + args.observe_seconds
|
||||
worker = [sys.executable, str(Path(__file__).resolve()), '--query-worker', '--host-display', args.host_display,
|
||||
'--prefix', str(args.prefix), '--game-executable', str(args.game_executable),
|
||||
'--profile', str(args.profile), '--report', str(args.report), '--observe-seconds', str(args.observe_seconds)]
|
||||
while child.poll() is None and not received and time.monotonic() < deadline:
|
||||
report['query_attempts'] += 1
|
||||
try:
|
||||
result = subprocess.run(worker, capture_output=True, text=True, timeout=min(3, max(0.01, deadline-time.monotonic())))
|
||||
if result.returncode:
|
||||
report['last_query_status'] = 'worker-exit'
|
||||
report['last_query_error'] = 'Read-only worker failed; exit '+str(result.returncode)
|
||||
if report['first_query_error'] is None:
|
||||
report['first_query_error'] = report['last_query_error']
|
||||
else:
|
||||
observed = json.loads(result.stdout)
|
||||
if observed.get('binding') != bound:
|
||||
raise ValueError('Original display process identity changed')
|
||||
report.update(observed)
|
||||
report['successful_queries'] += 1
|
||||
report['last_query_status'] = 'ok'
|
||||
report['last_window_count'] = observed.get('window_count')
|
||||
if report['first_window_count'] is None:
|
||||
report['first_window_count'] = observed.get('window_count')
|
||||
if report['owned_game_window_verified']:
|
||||
break
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as error:
|
||||
report['last_query_status'] = 'query-exception'
|
||||
report['last_query_error'] = type(error).__name__
|
||||
if report['first_query_error'] is None:
|
||||
report['first_query_error'] = report['last_query_error']
|
||||
time.sleep(0.2)
|
||||
report['observation_finished'] = True
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
fd = None
|
||||
json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
||||
returncode = child.wait() # do not alter engine lifetime; outer runner owns timeout/cleanup
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
if returncode < 0:
|
||||
signum = -returncode
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
os.kill(os.getpid(), signum)
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError) as error:
|
||||
print('Display probe refused: '+str(error), file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
Reference in New Issue
Block a user