Fix stale drag overlay click routing and add real-click regression test

This commit is contained in:
Lawrence Chen 2026-02-19 05:21:15 -08:00
parent b2415020ec
commit 631f689aba
4 changed files with 364 additions and 10 deletions

View file

@ -188,10 +188,17 @@ final class FileDropOverlayView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
let pb = NSPasteboard(name: .drag)
if let types = pb.types, types.contains(.fileURL) {
return super.hitTest(point)
}
return nil
guard let types = pb.types, types.contains(.fileURL) else { return nil }
// The drag pasteboard can retain stale file types after a completed drag.
// Only participate during active drag-motion events.
let eventType = NSApp.currentEvent?.type
let isDragMouseEvent = eventType == .leftMouseDragged
|| eventType == .rightMouseDragged
|| eventType == .otherMouseDragged
guard isDragMouseEvent else { return nil }
return super.hitTest(point)
}
// MARK: Mouse forwarding safety net for the rare case where stale drag pasteboard
@ -200,10 +207,9 @@ final class FileDropOverlayView: NSView {
// window.sendEvent(), which caches the mouse target and causes infinite recursion.
private func forwardEvent(_ event: NSEvent) {
guard let window, let contentView = window.contentView,
let themeFrame = contentView.superview else { return }
guard let window, let contentView = window.contentView else { return }
isHidden = true
let point = themeFrame.convert(event.locationInWindow, from: nil)
let point = contentView.convert(event.locationInWindow, from: nil)
let target = contentView.hitTest(point)
isHidden = false
guard let target else { return }
@ -265,10 +271,9 @@ final class FileDropOverlayView: NSView {
return portalTerminal
}
guard let window, let contentView = window.contentView,
let themeFrame = contentView.superview else { return nil }
guard let window, let contentView = window.contentView else { return nil }
isHidden = true
let point = themeFrame.convert(windowPoint, from: nil)
let point = contentView.convert(windowPoint, from: nil)
let hitView = contentView.hitTest(point)
isHidden = false

View file

@ -475,6 +475,12 @@ class TerminalController {
case "simulate_file_drop":
return simulateFileDrop(args)
case "seed_drag_pasteboard_fileurl":
return seedDragPasteboardFileURL()
case "clear_drag_pasteboard":
return clearDragPasteboard()
case "drop_hit_test":
return dropHitTest(args)
@ -6266,6 +6272,8 @@ class TerminalController {
simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only)
simulate_type <text> - Insert text into the current first responder (test-only)
simulate_file_drop <id|idx> <path[|path...]> - Simulate dropping file path(s) on terminal (test-only)
seed_drag_pasteboard_fileurl - Seed NSDrag pasteboard with public.file-url (test-only)
clear_drag_pasteboard - Clear NSDrag pasteboard (test-only)
drop_hit_test <x 0-1> <y 0-1> - Hit-test file-drop overlay at normalised coords (test-only)
activate_app - Bring app + main window to front (test-only)
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
@ -6494,6 +6502,20 @@ class TerminalController {
return result
}
private func seedDragPasteboardFileURL() -> String {
DispatchQueue.main.sync {
_ = NSPasteboard(name: .drag).declareTypes([.fileURL], owner: nil)
}
return "OK"
}
private func clearDragPasteboard() -> String {
DispatchQueue.main.sync {
_ = NSPasteboard(name: .drag).clearContents()
}
return "OK"
}
/// Hit-tests the file-drop overlay's coordinate-to-terminal mapping.
/// Takes normalised (0-1) x,y within the content area where (0,0) is the
/// top-left corner and (1,1) is the bottom-right corner. Returns the

View file

@ -833,6 +833,18 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def seed_drag_pasteboard_fileurl(self) -> None:
"""Seed NSDrag pasteboard with public.file-url in the app process (debug builds only)."""
response = self._send_command("seed_drag_pasteboard_fileurl")
if not response.startswith("OK"):
raise cmuxError(response)
def clear_drag_pasteboard(self) -> None:
"""Clear NSDrag pasteboard in the app process (debug builds only)."""
response = self._send_command("clear_drag_pasteboard")
if not response.startswith("OK"):
raise cmuxError(response)
def drop_hit_test(self, x: float, y: float) -> Optional[str]:
"""Hit-test the file-drop overlay at normalised (0-1) coords.

View file

@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""
Regression test: stale file-drag overlay state must not swallow real mouse clicks.
This uses real HID mouse events (CoreGraphics CGEvent), not XCUI element actions.
It seeds the drag pasteboard with `public.file-url` to force the FileDropOverlayView
stale-drag path, then verifies:
1) A left click changes terminal focus to the clicked pane.
2) A real right click does not break terminal focus routing.
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def run_osascript(script: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=8,
)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
return result
def is_accessibility_error(err: subprocess.CalledProcessError) -> bool:
text = f"{getattr(err, 'stderr', '') or ''}\n{getattr(err, 'output', '') or ''}".lower()
needles = [
"not allowed to send keystrokes",
"not allowed assistive access",
"not allowed to control computer",
"(1002)",
]
return any(n in text for n in needles)
def app_name_for_bundle(bundle_id: str) -> str:
out = run_osascript(f'tell application id "{bundle_id}" to get name').stdout.strip()
if not out:
raise RuntimeError(f"Could not resolve app name for bundle ID {bundle_id}")
return out
def front_window_frame(app_name: str) -> tuple[float, float, float, float]:
script = f'''
tell application "System Events"
tell process "{app_name}"
tell front window
set p to position
set s to size
return (item 1 of p as text) & "," & (item 2 of p as text) & "," & (item 1 of s as text) & "," & (item 2 of s as text)
end tell
end tell
end tell
'''
raw = run_osascript(script).stdout.strip()
parts = [p.strip() for p in raw.split(",")]
if len(parts) != 4:
raise RuntimeError(f"Unexpected window frame from osascript: {raw}")
x, y, w, h = (float(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]))
return x, y, w, h
def post_click_with_cgevent(x: float, y: float, right: bool = False) -> None:
ix = int(round(x))
iy = int(round(y))
if right:
down = ".rightMouseDown"
up = ".rightMouseUp"
button = ".right"
else:
down = ".leftMouseDown"
up = ".leftMouseUp"
button = ".left"
code = f"""
import CoreGraphics
let p = CGPoint(x: {ix}, y: {iy})
let source = CGEventSource(stateID: .hidSystemState)
let down = CGEvent(mouseEventSource: source, mouseType: {down}, mouseCursorPosition: p, mouseButton: {button})
let up = CGEvent(mouseEventSource: source, mouseType: {up}, mouseCursorPosition: p, mouseButton: {button})
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
"""
subprocess.run(
["swift", "-e", code],
check=True,
capture_output=True,
text=True,
timeout=10,
)
def pick_top_bottom_terminal_panels(layout: dict) -> tuple[dict, dict]:
candidates = []
for panel in layout.get("selectedPanels", []):
if panel.get("panelType") != "terminal":
continue
view = panel.get("viewFrame")
if not isinstance(view, dict):
continue
if not panel.get("panelId"):
continue
candidates.append(panel)
if len(candidates) < 2:
raise RuntimeError(f"Expected >=2 terminal panels with viewFrame, got: {candidates}")
candidates.sort(key=lambda p: float(p["viewFrame"]["y"]))
bottom = candidates[0]
top = candidates[-1]
if bottom["panelId"] == top["panelId"]:
raise RuntimeError("Top/bottom panel IDs collapsed to the same panel")
return top, bottom
def candidate_screen_points(
window_x: float, window_y: float, window_h: float, panel: dict
) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
pane = panel.get("paneFrame") or {}
view = panel.get("viewFrame") or {}
window_points: list[tuple[float, float]] = []
if pane:
px = float(pane["x"])
py = float(pane["y"])
pw = float(pane["width"])
ph = float(pane["height"])
window_points.extend([
(px + pw * 0.50, py + ph * 0.50),
(px + pw * 0.50, py + min(24.0, ph * 0.20)),
(px + pw * 0.50, py + max(ph - 24.0, ph * 0.80)),
])
if view:
vx = float(view["x"])
vy = float(view["y"])
vw = float(view["width"])
vh = float(view["height"])
window_points.extend([
(vx + vw * 0.50, vy + vh * 0.50),
(vx + vw * 0.50, vy + min(24.0, vh * 0.20)),
(vx + vw * 0.50, vy + max(vh - 24.0, vh * 0.80)),
])
# Try both y-axis interpretations; multi-display setups and coordinate-space
# conversions can differ by API surface.
for wx, wy in window_points:
points.append((window_x + wx, window_y + wy))
points.append((window_x + wx, window_y + (window_h - wy)))
# Deduplicate while preserving order.
dedup: list[tuple[float, float]] = []
seen: set[tuple[int, int]] = set()
for sx, sy in points:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
dedup.append((sx, sy))
return dedup
def wait_for_terminal_focus(client: cmux, panel_id: str, timeout_s: float = 2.0) -> bool:
start = time.time()
while time.time() - start < timeout_s:
try:
if client.is_terminal_focused(panel_id):
return True
except Exception:
pass
time.sleep(0.05)
return False
def attempt_focus_via_real_clicks(
client: cmux,
panel_id: str,
points: list[tuple[float, float]],
) -> tuple[bool, tuple[float, float]]:
last_point = points[0]
for tx, ty in points:
last_point = (tx, ty)
for _ in range(2):
post_click_with_cgevent(tx, ty, right=False)
if wait_for_terminal_focus(client, panel_id, timeout_s=0.35):
return True, (tx, ty)
return False, last_point
def main() -> int:
socket_path = cmux.default_socket_path()
if not os.path.exists(socket_path):
print(f"SKIP: Socket not found at {socket_path}")
print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
return 0
bundle_id = cmux.default_bundle_id()
try:
app_name = app_name_for_bundle(bundle_id)
except subprocess.CalledProcessError as e:
print(f"SKIP: Could not resolve app name for bundle {bundle_id}: {e}")
return 0
with cmux(socket_path) as client:
ws_id = None
try:
client.activate_app()
time.sleep(0.2)
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.3)
client.new_split("down")
time.sleep(0.5)
layout = client.layout_debug()
top_panel, bottom_panel = pick_top_bottom_terminal_panels(layout)
top_id = top_panel["panelId"]
bottom_id = bottom_panel["panelId"]
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: bottom pane unexpectedly focused before click precondition")
return 1
win_x, win_y, _win_w, win_h = front_window_frame(app_name)
candidate_points = candidate_screen_points(win_x, win_y, win_h, bottom_panel)
# Baseline: real HID click routing must work before we can assert stale-pasteboard regression.
client.activate_app()
time.sleep(0.2)
baseline_ok, baseline_point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
if not baseline_ok:
print("SKIP: real HID clicks are not routable on this host right now")
return 0
client.focus_surface_by_panel(top_id)
time.sleep(0.2)
if client.is_terminal_focused(bottom_id):
print("FAIL: could not restore top-pane precondition before stale-pasteboard check")
return 1
client.seed_drag_pasteboard_fileurl()
client.activate_app()
time.sleep(0.2)
focused, point = attempt_focus_via_real_clicks(client, bottom_id, candidate_points)
click_x, click_y = point
if not focused:
print("FAIL: real left click did not focus clicked pane under stale drag pasteboard")
print(
"baseline_point="
f"({baseline_point[0]:.1f}, {baseline_point[1]:.1f}) "
f"click_screen=({click_x:.1f}, {click_y:.1f})"
)
print(f"top_id={top_id} bottom_id={bottom_id}")
print(f"layout={layout}")
return 1
post_click_with_cgevent(click_x, click_y, right=True)
time.sleep(0.25)
if not client.is_terminal_focused(bottom_id):
print("FAIL: real right click disrupted terminal focus routing")
return 1
print("PASS: stale file-drag overlay forwards real left/right clicks")
print(f" focused_panel={bottom_id}")
return 0
finally:
try:
client.clear_drag_pasteboard()
except Exception:
pass
if ws_id:
try:
client.close_workspace(ws_id)
except Exception:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as e:
if is_accessibility_error(e):
print("SKIP: System Events click automation not allowed (Accessibility permission missing)")
raise SystemExit(0)
print(f"FAIL: osascript invocation failed: {e}")
if getattr(e, "stderr", None):
print(e.stderr.strip())
if getattr(e, "output", None):
print(e.output.strip())
raise SystemExit(1)
except cmuxError as e:
print(f"FAIL: {e}")
raise SystemExit(1)