diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 42f13b08..4e899f92 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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 diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f8ae6941..2d6b2bee 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 - Simulate a keyDown shortcut (test-only) simulate_type - Insert text into the current first responder (test-only) simulate_file_drop - 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 - Hit-test file-drop overlay at normalised coords (test-only) activate_app - Bring app + main window to front (test-only) is_terminal_focused - 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 diff --git a/tests/cmux.py b/tests/cmux.py index 044c08c1..417d0716 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -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. diff --git a/tests/test_real_click_overlay_forwarding.py b/tests/test_real_click_overlay_forwarding.py new file mode 100644 index 00000000..f38d7381 --- /dev/null +++ b/tests/test_real_click_overlay_forwarding.py @@ -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)