Add native Wayland file dragging for SDL2 and SDL3

- Load Wayland APIs dynamically and track genuine pointer press serials.
- Serve file URI lists on a dedicated queue and transfer thread.
- Render native drag icons with Copy, rejected and pending feedback.
- Use memfd_create on Linux with a portable temporary-file fallback.
- Suppress synthetic gesture releases and clean up on cancellation,
  window destruction and process exit.
- Add isolated Weston integration tests and document API requirements.
This commit is contained in:
Martín Lucas Golini
2026-10-15 10:58:58 -03:00
parent ad69ee977e
commit ee8f560bde
25 changed files with 1863 additions and 28 deletions
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Run the explicit Wayland drag checks in Weston inside an isolated Xvfb display.
Requires Weston with its X11 backend, Xvfb, xdotool and ImageMagick's import.
Build eepp-native-dnd-tests first. Normal/manual runs never inject input.
"""
import argparse
import io
import os
import re
from pathlib import Path
import selectors
import shutil
import signal
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[2]
def read_line(process, prefix, timeout=25):
deadline = time.monotonic() + timeout
# Unbuffered binary stdout avoids hidden read-ahead when selecting a pipe.
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
pending = bytearray()
while time.monotonic() < deadline:
if not selector.select(max(0, deadline - time.monotonic())):
break
byte = process.stdout.read(1)
if not byte:
raise RuntimeError(f"Test process exited with {process.poll()}")
if byte != b"\n":
pending += byte
continue
line = pending.decode(errors="replace").strip()
pending.clear()
print(line, flush=True)
if line.startswith(prefix):
return line
raise RuntimeError(f"Timed out waiting for {prefix}")
def pointer(*arguments):
subprocess.run(["xdotool", *map(str, arguments)], check=True)
def color_point(color, compact=False):
# PPM keeps this runner independent of Python image packages.
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
stream = io.BytesIO(
subprocess.check_output(["import", "-window", "root", "-depth", "8", "ppm:-"])
)
assert stream.readline().strip() == b"P6"
line = stream.readline()
while line.startswith(b"#"):
line = stream.readline()
width, height = map(int, line.split())
assert stream.readline().strip() == b"255"
pixels = stream.read()
wanted = bytes(color)
# Window centers need room for the drag threshold; badges need only a small solid patch.
radius = 2 if compact else 24
candidates = []
for y in range(60, height - 30, 6):
for x in range(40, width - 40, 6):
if all(
pixels[(yy * width + xx) * 3 : (yy * width + xx) * 3 + 3] == wanted
for xx, yy in (
(x, y),
(x - radius, y),
(x + radius, y),
(x, y + min(radius, 20)),
)
):
if compact:
return x, y
candidates.append((x, y))
if candidates:
# Prefer the middle of the visible window, keeping the preview away from output edges.
center_x = (min(x for x, _ in candidates) + max(x for x, _ in candidates)) / 2
center_y = (min(y for _, y in candidates) + max(y for _, y in candidates)) / 2
return min(candidates, key=lambda p: (p[0] - center_x) ** 2 + (p[1] - center_y) ** 2)
time.sleep(0.1)
raise RuntimeError(f"No visible test surface with color {color}")
def run(binary, log_dir, scale):
runtime = tempfile.mkdtemp(prefix="eepp-weston-")
environment = dict(
os.environ,
XDG_RUNTIME_DIR=runtime,
WAYLAND_DISPLAY="eepp-dnd-test",
SDL_VIDEO_DRIVER="wayland",
SDL_VIDEODRIVER="wayland",
LIBGL_ALWAYS_SOFTWARE="1",
SDL_VIDEO_WAYLAND_ALLOW_LIBDECOR="0",
)
children = []
files = []
def launch(arguments, name, protocol=False):
log = (log_dir / (name + ".log")).open("wb")
files.append(log)
child_env = dict(environment)
if protocol:
child_env["WAYLAND_DEBUG"] = "1"
# Only Weston uses X11. The applications must operate as pure Wayland clients.
child_env.pop("DISPLAY", None)
child_env.pop("XAUTHORITY", None)
child = subprocess.Popen(
arguments,
cwd=ROOT,
env=child_env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=log,
bufsize=0,
)
children.append(child)
return child
try:
weston = launch(
[
"weston",
"--backend=x11",
"--renderer=pixman",
f"--width={1240 // scale}",
f"--height={760 // scale}",
f"--scale={scale}",
"--socket=eepp-dnd-test",
"--no-config",
"--idle-time=0",
"--log=" + str(log_dir / "weston.log"),
],
"weston-stderr",
)
deadline = time.monotonic() + 10
while not Path(runtime, "eepp-dnd-test").exists():
if weston.poll() is not None or time.monotonic() > deadline:
raise RuntimeError("Weston could not start; inspect its log")
time.sleep(0.1)
tests = launch([str(binary), "--automated-wayland"], "source-protocol", True)
for scenario in (
"snapshot",
"large",
"rejected",
"cancel",
"escape",
"destroy",
"external",
"instant",
"stalled-cancel",
"shutdown",
):
ready = read_line(tests, "WAYLAND_DND_READY")
assert ready.endswith(scenario), ready
external = None
if scenario == "external":
external = launch(
[str(binary), "--automated-wayland-target"], "target-protocol", True
)
read_line(external, "WAYLAND_DND_EXTERNAL_READY")
time.sleep(0.25)
source = color_point((180, 30, 50))
pointer("mousemove", *source, "click", 1)
time.sleep(0.2)
source = color_point((180, 30, 50))
target = color_point((135, 45, 185) if external else (30, 80, 190))
pointer("mousemove", *source, "mousedown", 1)
time.sleep(0.1)
pointer("mousemove", source[0] + 18, source[1] + 4)
if scenario == "stalled-cancel":
read_line(tests, "WAYLAND_DND_BEFORE_START")
# Stop Weston before native setup, ensuring cancellation cannot use a cached reply.
# Resume it before rendering: swap/frame callbacks may legitimately wait for it.
weston.send_signal(signal.SIGSTOP)
try:
tests.stdin.write(b"begin\n")
tests.stdin.flush()
read_line(tests, "WAYLAND_DND_STARTED", timeout=2)
tests.stdin.write(b"cleanup\n")
tests.stdin.flush()
read_line(tests, "WAYLAND_DND_CLEANUP_DONE", timeout=2)
finally:
weston.send_signal(signal.SIGCONT)
else:
read_line(tests, "WAYLAND_DND_STARTED")
if scenario == "escape":
pointer("key", "Escape")
elif scenario == "rejected":
pointer("mousemove", 20, 600)
color_point((195, 45, 55), compact=True)
elif scenario not in ("cancel", "destroy", "instant", "stalled-cancel", "shutdown"):
pointer("mousemove", *target)
time.sleep(0.2)
color_point((31, 150, 82), compact=True)
if scenario == "snapshot":
pointer("mousemove", 20, 600)
color_point((195, 45, 55), compact=True)
pointer("mousemove", *target)
color_point((31, 150, 82), compact=True)
if scenario in ("snapshot", "external"):
color_point((40, 220, 100))
subprocess.run(
["import", "-window", "root", str(log_dir / "snapshot.png")], check=True
)
pointer("mouseup", 1)
result = read_line(tests, "WAYLAND_DND_RESULT")
assert " PASS " in result, result
if external:
assert read_line(external, "WAYLAND_DND_EXTERNAL ").endswith("PASS")
assert external.wait(timeout=5) == 0
time.sleep(0.1)
if scenario not in ("destroy", "shutdown"):
tests.stdin.write(b"click\n")
tests.stdin.flush()
read_line(tests, "WAYLAND_DND_CLICK_READY")
pointer("mousemove", *color_point((180, 30, 50)), "click", 1)
assert read_line(tests, "WAYLAND_DND_CLICK ").endswith("PASS")
if scenario != "shutdown":
tests.stdin.write(b"next\n")
tests.stdin.flush()
assert tests.wait(timeout=5) == 0
exiting = launch([str(binary), "--automated-wayland-exit"], "process-exit-protocol", True)
read_line(exiting, "WAYLAND_DND_READY shutdown")
time.sleep(0.25)
source = color_point((180, 30, 50))
pointer("mousemove", *source, "click", 1)
time.sleep(0.2)
source = color_point((180, 30, 50))
pointer("mousemove", *source, "mousedown", 1)
time.sleep(0.1)
pointer("mousemove", source[0] + 18, source[1] + 4)
read_line(exiting, "WAYLAND_DND_PROCESS_EXIT")
assert exiting.wait(timeout=5) == 0
pointer("mouseup", 1)
print("Wayland process exit with a live backend passed")
protocol = (log_dir / "source-protocol.log").read_text(errors="replace")
created = len(re.findall(r"\.data_offer\(new id wl_data_offer#", protocol))
destroyed = len(re.findall(r"-> wl_data_offer#\d+\.destroy\(\)", protocol))
assert created > 0 and created == destroyed, (created, destroyed)
print(f"Wayland offer proxies: {created} created, {destroyed} destroyed")
print("Wayland drag checks passed; logs:", log_dir)
except Exception:
subprocess.run(["import", "-window", "root", str(log_dir / "failure.png")], check=False)
raise
finally:
subprocess.run(
["xdotool", "mouseup", "1"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for child in reversed(children):
if child.poll() is None:
child.terminate()
for child in children:
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
for file in files:
file.close()
shutil.rmtree(runtime)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inside-xvfb", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--binary", type=Path, default=ROOT / "bin/eepp-native-dnd-tests")
parser.add_argument("--log-dir", type=Path)
parser.add_argument("--scale", type=int, choices=(1, 2), default=1)
args = parser.parse_args()
if not args.inside_xvfb:
return subprocess.call(
[
str(ROOT / "projects/scripts/xvfb-run-eepp"),
sys.executable,
str(Path(__file__).resolve()),
"--inside-xvfb",
*sys.argv[1:],
]
)
log_dir = (args.log_dir or Path(tempfile.mkdtemp(prefix="eepp-wayland-dnd-logs-"))).resolve()
log_dir.mkdir(parents=True, exist_ok=True)
print("Wayland test logs:", log_dir, flush=True)
run(args.binary.resolve(), log_dir, args.scale)
return 0
if __name__ == "__main__":
sys.exit(main())