Files
reverse-engineering-anything/scripts/hopper-demo-x11.py
T
HenglieandCursor Agent b1ff7cdacc fix(hopper): distinguish an absent demo dialog from publisher rejection (#8074)
* fix(hopper): distinguish an absent demo dialog from publisher rejection

The Linux demo watcher collapsed an admitted launcher with no first-run dialog into unsupported_demo_dialog. Report that post-authentication timeout as demo_dialog_absent, keep the unsupported dialog code for an observed but unrecognized Hopper window, and allow REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS to widen the fixed wait. Closes #8008.

* fix(hopper): tolerate callers without the dialog timeout argument

* fix(hopper): report an invalid demo dialog timeout as its own failure

An invalid REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS exited 73 and was shown as
invalid_launch_command. Exit 82 is invalid_dialog_timeout. The public error
names the setting, shows the rejected value and source, lists the allowed
range, and says to restart REA.

Drive the demo watcher with controlled windows and time. No window exits 81,
a Hopper window without a supported dialog exits 76, and a dialog that arrives
after the old 10 second limit is still accepted.

* fix(hopper): keep startup stderr capture on AdapterFailure

DemoWatchStop was inserted above with_stderr, so launch failures called a
method they no longer had and became unexpected_failure. Restore with_stderr
on AdapterFailure and reject a watcher fixture that cannot capture stderr.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-30 18:29:18 +08:00

990 lines
32 KiB
Python

#!/usr/bin/env python3
"""Inspect or activate Hopper's authenticated demo dialog on a private X display."""
from __future__ import annotations
import argparse
from collections.abc import Callable
import ctypes
import hashlib
import json
import os
import re
from pathlib import Path
import secrets
import select
import stat
import subprocess
import tempfile
import threading
import time
EXPECTED_SCREEN = (1280, 1024)
DIALOG_TITLES = {"Registration", "Registration — Hopper Disassembler"}
HOPPER_WINDOW_TITLE = "Hopper Disassembler"
DEMO_CLICK_LEFT = 116
DEMO_CLICK_BOTTOM = 22
PRIVATE_DISPLAY_UNAVAILABLE = 70
X11_AUTHORIZATION_FAILED = 71
UNSUPPORTED_HOPPER_BUILD = 72
INVALID_LAUNCH_COMMAND = 73
PROCESS_OWNERSHIP_MISMATCH = 74
HOPPER_EXITED_DURING_STARTUP = 75
UNSUPPORTED_DEMO_DIALOG = 76
UNEXPECTED_DISPLAY_GEOMETRY = 77
X11_INPUT_FAILED = 78
RUNTIME_DEPENDENCY_UNAVAILABLE = 79
X11_SOCKET_DIRECTORY_UNUSABLE = 80
DEMO_DIALOG_ABSENT = 81
INVALID_DIALOG_TIMEOUT = 82
DEFAULT_DIALOG_TIMEOUT_MS = 10_000
DIALOG_TIMEOUT_MINIMUM_MS = 1
# Node schedules a delay above this signed 32-bit bound as 1 ms. Keep the
# watcher on the same ceiling as REA's other millisecond settings.
DIALOG_TIMEOUT_MAXIMUM_MS = 2_147_483_647
DIALOG_TIMEOUT_ENV = "REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS"
DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC="
X11_SOCKET_DIRECTORY = Path("/tmp/.X11-unix")
class AdapterFailure(Exception):
def __init__(
self,
code: int,
reason: str,
message: str,
stderr_bytes: int = 0,
dialog_timeout: dict[str, object] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.reason = reason
self.stderr_bytes = stderr_bytes
self.dialog_timeout = dialog_timeout
def with_stderr(self, capture: CapturedStderr) -> AdapterFailure:
self.stderr_bytes, _ = capture.snapshot()
return self
class DemoWatchStop(Exception):
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
class CapturedStderr:
def __init__(self, stream: object) -> None:
self._stream = stream
self._retained = bytearray()
self._bytes = 0
self._lock = threading.Lock()
self._thread = threading.Thread(target=self._drain, daemon=True)
self._thread.start()
def _drain(self) -> None:
while True:
chunk = self._stream.read(4096)
if not chunk:
return
with self._lock:
self._bytes += len(chunk)
self._retained.extend(chunk)
def snapshot(self) -> tuple[int, str]:
with self._lock:
retained = bytes(self._retained)
total = self._bytes
return total, retained.decode("utf-8", "replace")
def close(self) -> None:
self._thread.join(timeout=0.5)
class XWindowAttributes(ctypes.Structure):
_fields_ = [
("x", ctypes.c_int),
("y", ctypes.c_int),
("width", ctypes.c_int),
("height", ctypes.c_int),
("border_width", ctypes.c_int),
("depth", ctypes.c_int),
("visual", ctypes.c_void_p),
("root", ctypes.c_ulong),
("class_", ctypes.c_int),
("bit_gravity", ctypes.c_int),
("win_gravity", ctypes.c_int),
("backing_store", ctypes.c_int),
("backing_planes", ctypes.c_ulong),
("backing_pixel", ctypes.c_ulong),
("save_under", ctypes.c_int),
("colormap", ctypes.c_ulong),
("map_installed", ctypes.c_int),
("map_state", ctypes.c_int),
("all_event_masks", ctypes.c_long),
("your_event_mask", ctypes.c_long),
("do_not_propagate_mask", ctypes.c_long),
("override_redirect", ctypes.c_int),
("screen", ctypes.c_void_p),
]
def rejected_timeout_value(value: str) -> str:
visible = "".join(
character if character.isprintable() else "\ufffd" for character in value
)
if len(visible) <= 128:
return visible
return f"{visible[:127]}…"
def dialog_timeout_message(value: str | None) -> str:
current = (
"was rejected"
if value is None
else f"is {json.dumps(value, ensure_ascii=False)} ms"
)
return (
f"The effective {DIALOG_TIMEOUT_ENV} value {current} "
f"(source setting; default {DEFAULT_DIALOG_TIMEOUT_MS} ms, "
f"floor {DIALOG_TIMEOUT_MINIMUM_MS} ms, "
f"maximum {DIALOG_TIMEOUT_MAXIMUM_MS} ms). "
f"Set {DIALOG_TIMEOUT_ENV} in the environment that starts REA to a "
f"positive decimal integer from {DIALOG_TIMEOUT_MINIMUM_MS} to "
f"{DIALOG_TIMEOUT_MAXIMUM_MS}, then restart REA."
)
def dialog_timeout_decimal(value: str) -> int | None:
if re.fullmatch(r"[1-9]\d{0,9}", value) is None:
return None
parsed = int(value)
if parsed > DIALOG_TIMEOUT_MAXIMUM_MS:
return None
return parsed
def dialog_timeout_rejection(value: str) -> AdapterFailure:
shown = rejected_timeout_value(value)
return AdapterFailure(
INVALID_DIALOG_TIMEOUT,
"invalid_dialog_timeout",
dialog_timeout_message(shown),
dialog_timeout={
"setting": DIALOG_TIMEOUT_ENV,
"value": shown,
"source": "setting",
"minimum_ms": DIALOG_TIMEOUT_MINIMUM_MS,
"maximum_ms": DIALOG_TIMEOUT_MAXIMUM_MS,
"default_ms": DEFAULT_DIALOG_TIMEOUT_MS,
},
)
def positive_timeout(value: str) -> int:
parsed = dialog_timeout_decimal(value)
if parsed is None:
raise argparse.ArgumentTypeError(
"dialog timeout must be a positive decimal integer from "
f"{DIALOG_TIMEOUT_MINIMUM_MS} to {DIALOG_TIMEOUT_MAXIMUM_MS}"
)
return parsed
def configured_dialog_timeout_ms(requested: int | None) -> int:
if requested is not None:
return requested
configured = os.environ.get(DIALOG_TIMEOUT_ENV)
if configured is None:
return DEFAULT_DIALOG_TIMEOUT_MS
parsed = dialog_timeout_decimal(configured)
if parsed is None:
raise dialog_timeout_rejection(configured)
return parsed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--probe", action="store_true")
parser.add_argument("--owner-stdin", action="store_true")
parser.add_argument("--hopper")
parser.add_argument("--expected-launcher-sha256")
parser.add_argument("--socket")
parser.add_argument(
"--strategy",
choices=("direct", "user-mount-namespace"),
default="direct",
)
parser.add_argument("--mount-private-x11", action="store_true")
parser.add_argument("--xvfb", default="/usr/bin/Xvfb")
parser.add_argument("--xauth", default="/usr/bin/xauth")
parser.add_argument("--display-number", type=int)
parser.add_argument("--dialog-timeout-ms", type=positive_timeout)
parser.add_argument("command", nargs=argparse.REMAINDER)
return parser.parse_args()
class OwnerLifetime:
"""The launcher's private pipe is a lifetime capability, independent of PIDs."""
def __init__(self, enabled: bool) -> None:
self._poll = select.poll() if enabled else None
if self._poll is not None:
# Node/libuv can implement piped stdio with a Unix socket pair.
mode = os.fstat(0).st_mode
if not (stat.S_ISFIFO(mode) or stat.S_ISSOCK(mode)):
raise AdapterFailure(
INVALID_LAUNCH_COMMAND,
"invalid_launch_command",
"owner lifetime requires a private stdin pipe",
)
self._poll.register(0, select.POLLIN | select.POLLHUP | select.POLLERR)
def alive(self) -> bool:
if self._poll is None:
return True
for _, events in self._poll.poll(0):
if events & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
return False
if events & select.POLLIN and not os.read(0, 4096):
return False
return True
def wait_for_hopper(child: subprocess.Popen[bytes], owner: OwnerLifetime) -> int:
while owner.alive():
try:
return child.wait(timeout=0.1)
except subprocess.TimeoutExpired:
continue
# run_launch's finally owns cleanup of both direct, unreaped children.
return 0
def digest(path: Path) -> str:
value = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
def descendants(pid: int) -> set[int]:
found = {pid}
changed = True
while changed:
changed = False
for stat in Path("/proc").glob("[0-9]*/stat"):
try:
fields = stat.read_text().split()
child = int(fields[0])
parent = int(fields[3])
except (OSError, ValueError, IndexError):
continue
if parent in found and child not in found:
found.add(child)
changed = True
return found
def hopper_is_owned(leader: int, hopper: Path) -> bool:
expected = hopper.resolve()
for pid in descendants(leader):
try:
if Path(f"/proc/{pid}/exe").resolve() == expected:
return True
except OSError:
continue
return False
def windows(x11: ctypes.CDLL, display: int, root: int) -> list[tuple[int, int, int, int, int]]:
found: list[tuple[int, int, int, int, int]] = []
pending = [root]
while pending:
window = pending.pop()
attributes = XWindowAttributes()
if x11.XGetWindowAttributes(display, window, ctypes.byref(attributes)):
if attributes.map_state == 2:
found.append(
(window, attributes.x, attributes.y, attributes.width, attributes.height)
)
root_return = ctypes.c_ulong()
parent_return = ctypes.c_ulong()
children = ctypes.POINTER(ctypes.c_ulong)()
count = ctypes.c_uint()
if x11.XQueryTree(
display,
window,
ctypes.byref(root_return),
ctypes.byref(parent_return),
ctypes.byref(children),
ctypes.byref(count),
):
pending.extend(children[index] for index in range(count.value))
if children:
x11.XFree(children)
return found
def window_title(x11: ctypes.CDLL, display: int, window: int) -> str | None:
title = ctypes.c_char_p()
if not x11.XFetchName(display, window, ctypes.byref(title)) or not title.value:
return None
try:
return title.value.decode("utf-8", "replace")
finally:
x11.XFree(ctypes.cast(title, ctypes.c_void_p))
def window_transient_for(
x11: ctypes.CDLL, display: int, window: int
) -> int | None:
parent = ctypes.c_ulong()
if not x11.XGetTransientForHint(display, window, ctypes.byref(parent)):
return None
return int(parent.value)
def find_demo_dialog(
x11: ctypes.CDLL,
display: int,
observed: list[tuple[int, int, int, int, int]],
) -> tuple[int, int, int, int, int] | None:
for candidate in observed:
window = candidate[0]
if window_title(x11, display, window) not in DIALOG_TITLES:
continue
parent = window_transient_for(x11, display, window)
if parent is None:
continue
if window_title(x11, display, parent) == HOPPER_WINDOW_TITLE:
return candidate
return None
def demo_click(dialog: tuple[int, int, int, int, int]) -> tuple[int, int]:
_window, x, y, _width, height = dialog
return x + DEMO_CLICK_LEFT, y + height - DEMO_CLICK_BOTTOM
def watch_for_demo_dialog(
x11: ctypes.CDLL,
display: int,
timeout_ms: int,
*,
owner_alive: Callable[[], bool],
socket_ready: Callable[[], bool],
process_owned: Callable[[], bool],
process_exited: Callable[[], bool],
monotonic: Callable[[], float],
sleep: Callable[[float], None],
list_windows: Callable[[], list[tuple[int, int, int, int, int]]],
) -> tuple[int, int, int, int, int]:
deadline = monotonic() + timeout_ms / 1000
hopper_window_observed = False
while monotonic() < deadline:
if not owner_alive():
raise DemoWatchStop("owner_closed")
if socket_ready():
raise DemoWatchStop("socket_ready")
if not process_owned():
if not process_exited():
sleep(0.1)
continue
raise AdapterFailure(
HOPPER_EXITED_DURING_STARTUP,
"hopper_exited_during_startup",
"owned Hopper process exited",
)
observed = list_windows()
hopper_window_observed = hopper_window_observed or any(
window_title(x11, display, candidate[0]) == HOPPER_WINDOW_TITLE
for candidate in observed
)
dialog = find_demo_dialog(x11, display, observed)
if dialog is not None:
return dialog
sleep(0.1)
raise classify_watch_timeout(process_owned(), hopper_window_observed)
def classify_watch_timeout(
owned: bool,
hopper_window_observed: bool,
) -> AdapterFailure:
if not owned:
return AdapterFailure(
PROCESS_OWNERSHIP_MISMATCH,
"process_ownership_mismatch",
"Hopper process ownership mismatch",
)
if hopper_window_observed:
return AdapterFailure(
UNSUPPORTED_DEMO_DIALOG,
"unsupported_demo_dialog",
"Hopper did not present a supported registration dialog",
)
return AdapterFailure(
DEMO_DIALOG_ABSENT,
"demo_dialog_absent",
"Hopper started without presenting a registration dialog",
)
def configure_x11(x11: ctypes.CDLL, xtst: ctypes.CDLL) -> None:
window_pointer = ctypes.POINTER(ctypes.c_ulong)
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.XDisplayWidth.argtypes = [ctypes.c_void_p, ctypes.c_int]
x11.XDisplayWidth.restype = ctypes.c_int
x11.XDisplayHeight.argtypes = [ctypes.c_void_p, ctypes.c_int]
x11.XDisplayHeight.restype = ctypes.c_int
x11.XGetWindowAttributes.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(XWindowAttributes),
]
x11.XGetWindowAttributes.restype = ctypes.c_int
x11.XFetchName.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_char_p),
]
x11.XFetchName.restype = ctypes.c_int
x11.XGetTransientForHint.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
]
x11.XGetTransientForHint.restype = ctypes.c_int
x11.XQueryTree.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
window_pointer,
window_pointer,
ctypes.POINTER(window_pointer),
ctypes.POINTER(ctypes.c_uint),
]
x11.XQueryTree.restype = ctypes.c_int
x11.XFree.argtypes = [ctypes.c_void_p]
x11.XFree.restype = ctypes.c_int
x11.XFlush.argtypes = [ctypes.c_void_p]
x11.XFlush.restype = ctypes.c_int
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
x11.XCloseDisplay.restype = ctypes.c_int
xtst.XTestFakeMotionEvent.argtypes = [
ctypes.c_void_p,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_ulong,
]
xtst.XTestFakeMotionEvent.restype = ctypes.c_int
xtst.XTestFakeButtonEvent.argtypes = [
ctypes.c_void_p,
ctypes.c_uint,
ctypes.c_int,
ctypes.c_ulong,
]
xtst.XTestFakeButtonEvent.restype = ctypes.c_int
def is_wsl() -> bool:
for path in (Path("/proc/sys/kernel/osrelease"), Path("/proc/version")):
try:
if "microsoft" in path.read_text().lower():
return True
except OSError:
continue
return False
def socket_directory_state() -> tuple[str | None, bool | None, bool]:
try:
value = X11_SOCKET_DIRECTORY.lstat()
except FileNotFoundError:
try:
read_only = bool(os.statvfs(X11_SOCKET_DIRECTORY.parent).f_flag & os.ST_RDONLY)
except OSError:
read_only = None
return None, read_only, False
except OSError:
return None, None, False
try:
read_only = bool(os.statvfs(X11_SOCKET_DIRECTORY).f_flag & os.ST_RDONLY)
except OSError:
read_only = None
return f"{stat.S_IMODE(value.st_mode):04o}", read_only, stat.S_ISDIR(value.st_mode)
def diagnostic(
args: argparse.Namespace,
operation: str,
status: str,
failure: AdapterFailure | None = None,
capture: CapturedStderr | None = None,
) -> dict[str, object]:
mode, read_only, _ = socket_directory_state()
captured_bytes, _ = (0, "") if capture is None else capture.snapshot()
record = {
"component": "hopper_private_display",
"operation": operation,
"status": status,
"failure_code": None if failure is None else failure_name(failure.code),
"reason": "ready" if failure is None else failure.reason,
"socket_directory": str(X11_SOCKET_DIRECTORY),
"socket_directory_mode": mode,
"mount_read_only": read_only,
"effective_socket_directory_mode": mode,
"effective_mount_read_only": read_only,
"wsl": is_wsl(),
"strategy": args.strategy,
"fallback_reason": None,
"xvfb_stderr_bytes": captured_bytes
if failure is None
else failure.stderr_bytes,
}
if failure is not None and failure.dialog_timeout is not None:
record["dialog_timeout"] = failure.dialog_timeout
return record
def failure_name(code: int) -> str:
return {
PRIVATE_DISPLAY_UNAVAILABLE: "private_display_unavailable",
X11_AUTHORIZATION_FAILED: "x11_authorization_failed",
UNSUPPORTED_HOPPER_BUILD: "unsupported_hopper_build",
INVALID_LAUNCH_COMMAND: "invalid_launch_command",
PROCESS_OWNERSHIP_MISMATCH: "process_ownership_mismatch",
HOPPER_EXITED_DURING_STARTUP: "hopper_exited_during_startup",
UNSUPPORTED_DEMO_DIALOG: "unsupported_demo_dialog",
UNEXPECTED_DISPLAY_GEOMETRY: "unexpected_display_geometry",
X11_INPUT_FAILED: "x11_input_failed",
RUNTIME_DEPENDENCY_UNAVAILABLE: "runtime_dependency_unavailable",
X11_SOCKET_DIRECTORY_UNUSABLE: "x11_socket_directory_unusable",
DEMO_DIALOG_ABSENT: "demo_dialog_absent",
INVALID_DIALOG_TIMEOUT: "invalid_dialog_timeout",
}[code]
def emit_diagnostic(value: dict[str, object]) -> None:
print(
DIAGNOSTIC_PREFIX + json.dumps(value, sort_keys=True, separators=(",", ":")),
file=os.sys.stderr,
)
def mount_private_x11() -> None:
try:
mounted = subprocess.run(
[
"/usr/bin/mount",
"-t",
"tmpfs",
"-o",
"mode=1777,nosuid,nodev",
"rea-x11",
str(X11_SOCKET_DIRECTORY),
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=2,
)
except FileNotFoundError as error:
raise AdapterFailure(
RUNTIME_DEPENDENCY_UNAVAILABLE,
"missing_mount",
"mount executable unavailable",
) from error
except subprocess.TimeoutExpired as error:
raise AdapterFailure(
X11_SOCKET_DIRECTORY_UNUSABLE,
"mount_failed",
"private X11 mount timed out",
) from error
if mounted.returncode != 0:
raise AdapterFailure(
X11_SOCKET_DIRECTORY_UNUSABLE,
"mount_failed",
"private X11 mount failed",
)
mode, read_only, directory = socket_directory_state()
if not directory or mode != "1777" or read_only is not False:
raise AdapterFailure(
X11_SOCKET_DIRECTORY_UNUSABLE,
"mount_validation_failed",
"private X11 mount validation failed",
)
def stop_process(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def authorize_display(
authority: Path,
display_name: str,
xauth_path: str,
) -> None:
try:
authorized = subprocess.run(
[
xauth_path,
"-q",
"-f",
str(authority),
"source",
"-",
],
input=("add %s . %s\n" % (display_name, secrets.token_hex(16))).encode("ascii"),
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=2,
)
except FileNotFoundError as error:
raise AdapterFailure(
RUNTIME_DEPENDENCY_UNAVAILABLE,
"missing_xauth",
"xauth executable unavailable",
) from error
except subprocess.TimeoutExpired as error:
raise AdapterFailure(
X11_AUTHORIZATION_FAILED,
"xauth_failed",
"private X authorization timed out",
) from error
if authorized.returncode != 0:
raise AdapterFailure(
X11_AUTHORIZATION_FAILED,
"xauth_failed",
"private X authorization failed",
)
try:
authority.chmod(0o600)
except OSError as error:
raise AdapterFailure(
X11_AUTHORIZATION_FAILED,
"xauth_failed",
"private X authorization file unavailable",
) from error
def classify_xvfb_failure(capture: CapturedStderr) -> AdapterFailure:
stderr_bytes, text = capture.snapshot()
mode, read_only, directory = socket_directory_state()
if read_only is True:
return AdapterFailure(
X11_SOCKET_DIRECTORY_UNUSABLE,
"socket_directory_read_only",
"X11 socket directory is read-only",
stderr_bytes,
)
if directory and mode != "1777":
return AdapterFailure(
X11_SOCKET_DIRECTORY_UNUSABLE,
"socket_directory_mode",
"X11 socket directory mode is not 1777",
stderr_bytes,
)
if mode is not None and not directory:
return AdapterFailure(
X11_SOCKET_DIRECTORY_UNUSABLE,
"socket_directory_type",
"X11 socket path is not a directory",
stderr_bytes,
)
lowered = text.lower()
if "error while loading shared libraries" in lowered:
return AdapterFailure(
RUNTIME_DEPENDENCY_UNAVAILABLE,
"missing_library",
"Xvfb shared library unavailable",
stderr_bytes,
)
if any(
marker in lowered
for marker in (
"server is already active for display",
"address already in use",
"cannot establish any listening sockets",
)
):
return AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"address_collision",
"private display address collision",
stderr_bytes,
)
return AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"xvfb_startup_failed",
"private X display unavailable",
stderr_bytes,
)
def start_xvfb(
session: Path,
args: argparse.Namespace,
) -> tuple[subprocess.Popen[bytes], str, Path, CapturedStderr]:
authority = session / "Xauthority"
collision: AdapterFailure | None = None
for _ in range(3):
display_number = (
args.display_number
if args.display_number is not None
else 100 + secrets.randbelow(900)
)
display_name = f":{display_number}"
if Path(f"/tmp/.X11-unix/X{display_number}").exists() or Path(
f"/tmp/.X{display_number}-lock"
).exists():
collision = AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"address_collision",
"private display address collision",
)
if args.display_number is not None:
raise collision
continue
authorize_display(authority, display_name, args.xauth)
try:
process = subprocess.Popen(
[
args.xvfb,
display_name,
"-screen",
"0",
"1280x1024x24",
"-nolisten",
"tcp",
"-auth",
str(authority),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
except FileNotFoundError as error:
raise AdapterFailure(
RUNTIME_DEPENDENCY_UNAVAILABLE,
"missing_xvfb",
"Xvfb executable unavailable",
) from error
if process.stderr is None:
stop_process(process)
raise AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"xvfb_startup_failed",
"Xvfb stderr unavailable",
)
capture = CapturedStderr(process.stderr)
deadline = time.monotonic() + 2
socket = Path(f"/tmp/.X11-unix/X{display_number}")
while time.monotonic() < deadline and process.poll() is None:
if socket.is_socket():
return process, display_name, authority, capture
time.sleep(0.05)
stop_process(process)
capture.close()
failure = classify_xvfb_failure(capture)
if failure.reason != "address_collision" or args.display_number is not None:
raise failure
collision = failure
raise collision or AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"address_collision",
"private display address collision",
)
def run_probe(args: argparse.Namespace) -> dict[str, object]:
with tempfile.TemporaryDirectory(prefix="rea-x11-probe-", dir="/tmp") as root:
xvfb, _, _, capture = start_xvfb(Path(root), args)
try:
stop_process(xvfb)
capture.close()
return diagnostic(args, "probe", "ready", capture=capture)
finally:
stop_process(xvfb)
capture.close()
def run_launch(args: argparse.Namespace) -> int:
if (
args.hopper is None or args.socket is None
or args.expected_launcher_sha256 is None
or re.fullmatch(r"[a-f0-9]{64}", args.expected_launcher_sha256) is None
):
raise AdapterFailure(
INVALID_LAUNCH_COMMAND,
"invalid_launch_command",
"missing Hopper launch coordinates",
)
owner = OwnerLifetime(args.owner_stdin)
if not owner.alive():
return 0
hopper = Path(args.hopper)
socket = Path(args.socket)
try:
# The parent owns publisher/package interpretation. Recheck its admitted
# launcher bytes immediately before execution, preserving all X11 guards.
supported = digest(hopper) == args.expected_launcher_sha256
except OSError as error:
raise AdapterFailure(
INVALID_LAUNCH_COMMAND,
"invalid_launch_command",
"Hopper binary unavailable",
) from error
if not supported:
raise AdapterFailure(
UNSUPPORTED_HOPPER_BUILD,
"unsupported_hopper_build",
"Hopper binary differs from the authenticated release",
)
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command or Path(command[0]).resolve() != hopper.resolve():
raise AdapterFailure(
INVALID_LAUNCH_COMMAND,
"invalid_launch_command",
"invalid Hopper launch command",
)
timeout_ms = configured_dialog_timeout_ms(
getattr(args, "dialog_timeout_ms", None)
)
xvfb, display_name, authority, capture = start_xvfb(socket.parent, args)
child: subprocess.Popen[bytes] | None = None
display: int | None = None
try:
if not owner.alive():
return 0
hopper_environment = dict(os.environ)
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET"):
hopper_environment.pop(name, None)
hopper_environment.update(
{
"DISPLAY": display_name,
"XAUTHORITY": str(authority),
"GDK_BACKEND": "x11",
"QT_QPA_PLATFORM": "xcb",
"XDG_SESSION_TYPE": "x11",
}
)
# Children must not consume or retain REA's lifetime pipe.
child = subprocess.Popen(
command, env=hopper_environment, stdin=subprocess.DEVNULL
)
leader = os.getpid()
os.environ["XAUTHORITY"] = str(authority)
try:
x11 = ctypes.CDLL("libX11.so.6")
xtst = ctypes.CDLL("libXtst.so.6")
except OSError as error:
raise AdapterFailure(
RUNTIME_DEPENDENCY_UNAVAILABLE,
"missing_library",
"X11 runtime unavailable",
) from error
configure_x11(x11, xtst)
display = x11.XOpenDisplay(display_name.encode())
if not display:
raise AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"x11_connection_failed",
"private X display unavailable",
)
root = x11.XDefaultRootWindow(display)
if (
x11.XDisplayWidth(display, 0),
x11.XDisplayHeight(display, 0),
) != EXPECTED_SCREEN:
raise AdapterFailure(
UNEXPECTED_DISPLAY_GEOMETRY,
"unexpected_display_geometry",
"unexpected private display geometry",
)
try:
dialog = watch_for_demo_dialog(
x11,
display,
timeout_ms,
owner_alive=owner.alive,
socket_ready=socket.exists,
process_owned=lambda: hopper_is_owned(leader, hopper),
process_exited=lambda: child is not None and child.poll() is not None,
monotonic=time.monotonic,
sleep=time.sleep,
list_windows=lambda: windows(x11, display, root),
)
except DemoWatchStop as stopped:
if stopped.reason == "owner_closed":
return 0
x11.XCloseDisplay(display)
display = None
return wait_for_hopper(child, owner)
click_x, click_y = demo_click(dialog)
input_results = (
xtst.XTestFakeMotionEvent(display, -1, click_x, click_y, 0),
xtst.XTestFakeButtonEvent(display, 1, True, 0),
xtst.XTestFakeButtonEvent(display, 1, False, 0),
)
if not all(input_results):
raise AdapterFailure(
X11_INPUT_FAILED,
"x11_input_failed",
"private X input failed",
)
x11.XFlush(display)
x11.XCloseDisplay(display)
display = None
return wait_for_hopper(child, owner)
except AdapterFailure as error:
raise error.with_stderr(capture)
finally:
if display is not None:
x11.XCloseDisplay(display)
if child is not None:
stop_process(child)
stop_process(xvfb)
capture.close()
def main() -> int:
args = parse_args()
try:
if args.mount_private_x11:
mount_private_x11()
if args.probe:
emit_diagnostic(run_probe(args))
return 0
return run_launch(args)
except AdapterFailure as error:
emit_diagnostic(diagnostic(args, "probe" if args.probe else "launch", "error", error))
return error.code
except Exception:
failure = AdapterFailure(
PRIVATE_DISPLAY_UNAVAILABLE,
"unexpected_failure",
"private display adapter failed",
)
emit_diagnostic(diagnostic(args, "probe" if args.probe else "launch", "error", failure))
return failure.code
if __name__ == "__main__":
raise SystemExit(main())