"""Mouse/keyboard edge handoff over the JUC500 USB session. Layout: peer sits to the RIGHT of this PC (like an extended display). - Local control + cursor pushed past the right edge → control remote PC - Remote control + cursor pushed past the remote left edge → control this PC """ from __future__ import annotations import sys import threading import time from typing import Any, Optional from .protocol import ( INPUT_CTRL_LOCAL, INPUT_CTRL_REMOTE, INPUT_CTRL_REMOTE_ACK, INPUT_CTRL_REMOTE_NACK, INPUT_CTRL_SHARE_OFF, INPUT_CTRL_SHARE_ON, INPUT_CTRL_PEER_CLICK, Session, ) _IS_WIN = sys.platform == "win32" _IS_MAC = sys.platform == "darwin" # Bump when input-share behaviour changes (sync / peer version check). INPUTSHARE_BUILD = "2026-07-21-caps-hangul-attach-pulse" # Physical edge strip on this PC (screen coordinates) EDGE_PX = 28 # Wider strip on remote virtual desktop to return control (move mouse left) RETURN_EDGE_PX = 56 # How long cursor must stay in the return strip before handoff back RETURN_EDGE_DWELL_S = 0.12 # How long the cursor must stay in the strip before handoff EDGE_DWELL_S = 0.05 # After a handoff, ignore edges briefly to avoid bounce EDGE_COOLDOWN_S = 0.75 # Place cursor this far inside after returning to local (must clear EDGE_PX) LOCAL_RETURN_INSET = 96 # Start remote virtual cursor this far from the left edge REMOTE_ENTER_INSET = 80 # Slight boost — keep near 1.0; large gain amplifies jitter MOVE_GAIN = 1.35 # Capture-side coalesce window (~90–120 Hz source → fewer USB frames) MOVE_SEND_INTERVAL_S = 0.008 # Keep local cursor inside this inset of the grab display (poll fallback) GRAB_EDGE_MARGIN_PX = 48 # Ignore absurd per-poll jumps (display switch / warp glitch) MAX_DELTA_PER_POLL = 80 # After requesting remote control, wait this long for peer ACK before aborting REMOTE_ACK_TIMEOUT_S = 4.0 def _list_displays() -> list[tuple[float, float, float, float]]: """Return display rects as (x, y, w, h) in pynput/Quartz top-left coords.""" rects: list[tuple[float, float, float, float]] = [] if _IS_MAC: try: import Quartz err, ids, count = Quartz.CGGetActiveDisplayList(32, None, None) if err == 0 and ids: for did in ids[: int(count)]: b = Quartz.CGDisplayBounds(did) rects.append( ( float(b.origin.x), float(b.origin.y), float(b.size.width), float(b.size.height), ) ) except Exception: pass elif _IS_WIN: try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 MONITORINFOF_PRIMARY = 1 monitors: list[tuple[float, float, float, float]] = [] class RECT(ctypes.Structure): _fields_ = [ ("left", wintypes.LONG), ("top", wintypes.LONG), ("right", wintypes.LONG), ("bottom", wintypes.LONG), ] class MONITORINFO(ctypes.Structure): _fields_ = [ ("cbSize", wintypes.DWORD), ("rcMonitor", RECT), ("rcWork", RECT), ("dwFlags", wintypes.DWORD), ] MonitorEnumProc = ctypes.WINFUNCTYPE( wintypes.BOOL, wintypes.HMONITOR, wintypes.HDC, ctypes.POINTER(RECT), wintypes.LPARAM, ) def _cb(hmon, _hdc, _lprc, _data): info = MONITORINFO() info.cbSize = ctypes.sizeof(MONITORINFO) if user32.GetMonitorInfoW(hmon, ctypes.byref(info)): r = info.rcMonitor monitors.append( ( float(r.left), float(r.top), float(r.right - r.left), float(r.bottom - r.top), ) ) return True user32.EnumDisplayMonitors(0, 0, MonitorEnumProc(_cb), 0) rects = monitors except Exception: pass if rects: return rects try: import tkinter as tk root = tk.Tk() root.withdraw() w = float(root.winfo_screenwidth()) h = float(root.winfo_screenheight()) root.destroy() if w > 0 and h > 0: return [(0.0, 0.0, w, h)] except Exception: pass return [(0.0, 0.0, 1920.0, 1080.0)] def _screen_size() -> tuple[int, int]: """Primary (origin nearest 0,0) display size — used for grab center / peer size.""" rects = _list_displays() best = min(rects, key=lambda r: (abs(r[0]) + abs(r[1]), -r[2] * r[3])) return max(1, int(best[2])), max(1, int(best[3])) def _virtual_desktop() -> tuple[float, float, int, int]: """Union of all displays: (origin_x, origin_y, width, height).""" rects = _list_displays() if not rects: w, h = _screen_size() return 0.0, 0.0, w, h min_x = min(r[0] for r in rects) min_y = min(r[1] for r in rects) max_x = max(r[0] + r[2] for r in rects) max_y = max(r[1] + r[3] for r in rects) return ( float(min_x), float(min_y), max(1, int(max_x - min_x)), max(1, int(max_y - min_y)), ) def _display_at(x: float, y: float) -> tuple[float, float, float, float] | None: for r in _list_displays(): rx, ry, rw, rh = r if rx <= x < rx + rw and ry <= y < ry + rh: return r # Near-edge: allow a few px of slack outside for r in _list_displays(): rx, ry, rw, rh = r if rx - EDGE_PX <= x < rx + rw + EDGE_PX and ry - EDGE_PX <= y < ry + rh + EDGE_PX: return r return None def _has_neighbor_right( disp: tuple[float, float, float, float], y: float, gap: float = 24.0 ) -> bool: """True if another display continues past this display's right edge at row y.""" rx, ry, rw, rh = disp right = rx + rw for ox, oy, ow, oh in _list_displays(): if abs(ox - right) > gap: continue if oy <= y < oy + oh or ry <= oy < ry + rh: return True return False def _has_neighbor_left( disp: tuple[float, float, float, float], y: float, gap: float = 24.0 ) -> bool: """True if another display continues past this display's left edge at row y.""" rx, ry, rw, rh = disp left = rx for ox, oy, ow, oh in _list_displays(): if abs(ox + ow - left) > gap: continue if oy <= y < oy + oh or ry <= oy < ry + rh: return True return False def _left_reclaim_x(x: float, y: float) -> float | None: """Left edge x for reclaiming local control when peer is driving this PC.""" disp = _display_at(x, y) if disp is None: return None rx, ry, rw, rh = disp if _has_neighbor_left(disp, y): return None return rx def _right_handoff_x(x: float, y: float) -> float | None: """Right-edge X that should trigger peer handoff for cursor (x,y), or None.""" disp = _display_at(x, y) if disp is None: return None if _has_neighbor_right(disp, y): return None return disp[0] + disp[2] def _mac_mouse_delta() -> tuple[int, int]: """Read relative mouse delta without warping the cursor (macOS).""" try: from ctypes import CDLL, POINTER, byref, c_int cg = CDLL( "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics" ) cg.CGGetLastMouseDelta.argtypes = [POINTER(c_int), POINTER(c_int)] cg.CGGetLastMouseDelta.restype = None dx = c_int(0) dy = c_int(0) cg.CGGetLastMouseDelta(byref(dx), byref(dy)) return int(dx.value), int(dy.value) except Exception: pass try: import Quartz # Some PyObjC builds expose the helper directly t = Quartz.CGGetLastMouseDelta(None, None) if isinstance(t, tuple) and len(t) >= 2: return int(t[0]), int(t[1]) except Exception: pass return 0, 0 def _mac_set_mouse_associated(associated: bool) -> None: try: from ctypes import CDLL, c_int cg = CDLL( "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics" ) cg.CGAssociateMouseAndMouseCursorPosition.argtypes = [c_int] cg.CGAssociateMouseAndMouseCursorPosition.restype = None cg.CGAssociateMouseAndMouseCursorPosition(1 if associated else 0) except Exception: try: import Quartz Quartz.CGAssociateMouseAndMouseCursorPosition(bool(associated)) except Exception: pass def _mac_zero_event_suppression() -> None: """Set the local-events suppression interval to 0 so that warping the cursor back to a pin point does not swallow / inflate the next real mouse delta. This is what makes the reference (usbLAN) warp-back approach smooth: after a CGWarpMouseCursorPosition, macOS normally suppresses local events for ~0.25s which corrupts the per-event HID deltas we forward to the peer. """ try: from ctypes import CDLL, c_double, c_uint32, c_void_p cg = CDLL( "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics" ) cg.CGEventSourceCreate.argtypes = [c_uint32] cg.CGEventSourceCreate.restype = c_void_p src = cg.CGEventSourceCreate(1) or cg.CGEventSourceCreate(0) if src: cg.CGEventSourceSetLocalEventsSuppressionInterval.argtypes = [ c_void_p, c_double, ] cg.CGEventSourceSetLocalEventsSuppressionInterval(src, 0.0) except Exception: pass def _mac_post_event(ev: Any) -> None: """Post a synthetic event to all taps (HID + Session + Annotated).""" try: import Quartz taps = [Quartz.kCGHIDEventTap, Quartz.kCGSessionEventTap] ann = getattr(Quartz, "kCGAnnotatedSessionEventTap", None) if ann is not None: taps.append(ann) for tap in taps: try: Quartz.CGEventPost(tap, ev) except Exception: pass except Exception: pass def _mac_accessibility_ok() -> bool: try: import ApplicationServices # type: ignore return bool(ApplicationServices.AXIsProcessTrusted()) except Exception: pass try: import ctypes lib = ctypes.CDLL( "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices" ) lib.AXIsProcessTrusted.restype = ctypes.c_bool return bool(lib.AXIsProcessTrusted()) except Exception: return False def _mac_request_accessibility() -> bool: """Prompt macOS Accessibility dialog if not yet trusted.""" if _mac_accessibility_ok(): return True try: import ApplicationServices # type: ignore opts = { ApplicationServices.kAXTrustedCheckOptionPrompt.takeRetainedValue(): True } return bool(ApplicationServices.AXIsProcessTrustedWithOptions(opts)) except Exception: return False # macOS keyboard event taps need "Input Monitoring" (kIOHIDRequestTypeListenEvent), # which is a SEPARATE permission from Accessibility. Mouse taps work with # Accessibility alone, so a missing Input Monitoring grant shows up as # "mouse works but keyboard does nothing on the peer". _IOHID_REQUEST_TYPE_LISTEN_EVENT = 1 def _iokit_lib(): import ctypes return ctypes.CDLL("/System/Library/Frameworks/IOKit.framework/IOKit") def _mac_input_monitoring_ok() -> bool: """True when Input Monitoring is granted (keyboard tap can read events).""" if not _IS_MAC: return True try: import ctypes lib = _iokit_lib() lib.IOHIDCheckAccess.restype = ctypes.c_int lib.IOHIDCheckAccess.argtypes = [ctypes.c_uint] # 0 = granted, 1 = denied, 2 = unknown/not-determined return int(lib.IOHIDCheckAccess(_IOHID_REQUEST_TYPE_LISTEN_EVENT)) == 0 except Exception: # API unavailable (older macOS) — assume ok so we don't block handoff. return True def _mac_request_input_monitoring() -> bool: """Prompt the Input Monitoring dialog if keyboard capture is not yet allowed.""" if not _IS_MAC: return True if _mac_input_monitoring_ok(): return True try: import ctypes lib = _iokit_lib() lib.IOHIDRequestAccess.restype = ctypes.c_bool lib.IOHIDRequestAccess.argtypes = [ctypes.c_uint] return bool(lib.IOHIDRequestAccess(_IOHID_REQUEST_TYPE_LISTEN_EVENT)) except Exception: return False def _mac_osascript_click(ax: int, ay: int) -> bool: """Fallback click via System Events (needs Accessibility like pynput).""" try: import subprocess script = ( f'tell application "System Events" to click at ' f"{{{int(ax)}, {int(ay)}}}" ) r = subprocess.run( ["osascript", "-e", script], capture_output=True, timeout=3, check=False, ) return r.returncode == 0 except Exception: return False def _mac_click_at(ax: int, ay: int, button: int) -> bool: """Full click at Quartz global coords — warp then down+up.""" _mac_inject_mouse_pos(ax, ay) if _mac_osascript_click(ax, ay): return True if _mac_inject_mouse_button(ax, ay, button, True): time.sleep(0.025) _mac_inject_mouse_button(ax, ay, button, False) return True return False _MAC_MODIFIER_NAMES = frozenset( { "cmd", "cmd_l", "cmd_r", "command", "super", "windows", "win", "ctrl", "ctrl_l", "ctrl_r", "control", "shift", "shift_l", "shift_r", "alt", "alt_l", "alt_r", "option", "alt_gr", } ) _MAC_VK_ALIASES: dict[str, int] = { "cmd": 0x37, "cmd_l": 0x37, "command": 0x37, "super": 0x37, "windows": 0x37, "win": 0x37, "cmd_r": 0x36, "ctrl": 0x3B, "ctrl_l": 0x3B, "control": 0x3B, "ctrl_r": 0x3E, "shift": 0x38, "shift_l": 0x38, "shift_r": 0x3C, "alt": 0x3A, "alt_l": 0x3A, "option": 0x3A, "alt_r": 0x3D, "alt_gr": 0x3D, "space": 0x31, "tab": 0x30, "enter": 0x24, "return": 0x24, "esc": 0x35, "escape": 0x35, "backspace": 0x33, "delete": 0x75, "up": 0x7E, "down": 0x7D, "left": 0x7B, "right": 0x7C, "home": 0x73, "end": 0x77, "page_up": 0x74, "page_down": 0x79, "caps_lock": 0x39, } def _mac_vk_from_name(name: str) -> int | None: if not name: return None low = name.lower() if low in _MAC_VK_ALIASES: return _MAC_VK_ALIASES[low] if name.startswith("vk:"): try: return int(name.split(":", 1)[1]) & 0xFF except Exception: return None if len(name) == 1: try: from pynput._util.darwin import get_unicode_to_keycode_map return get_unicode_to_keycode_map().get(name) except Exception: return None if low.startswith("f") and low[1:].isdigit(): try: from pynput.keyboard._darwin import Key key = Key[low] return int(key.value.vk) if key.value.vk is not None else None except Exception: return None try: from pynput.keyboard._darwin import Key key = Key[low] return int(key.value.vk) if key.value.vk is not None else None except Exception: return None def _mac_modifier_flags(active: set[str]) -> int: try: import Quartz flags = 0 if active & {"cmd", "cmd_l", "cmd_r", "command", "super", "windows", "win"}: flags |= Quartz.kCGEventFlagMaskCommand if active & {"ctrl", "ctrl_l", "ctrl_r", "control"}: flags |= Quartz.kCGEventFlagMaskControl if active & {"shift", "shift_l", "shift_r"}: flags |= Quartz.kCGEventFlagMaskShift if active & {"alt", "alt_l", "alt_r", "option", "alt_gr"}: flags |= Quartz.kCGEventFlagMaskAlternate return int(flags) except Exception: return 0 def _mac_inject_key(name: str, pressed: bool, active_mods: set[str]) -> bool: """Inject keyboard event on macOS with modifier flags.""" try: import Quartz vk = _mac_vk_from_name(name) if vk is None: return False ev = Quartz.CGEventCreateKeyboardEvent(None, int(vk), bool(pressed)) if ev is None: return False flags = _mac_modifier_flags(active_mods) if flags: Quartz.CGEventSetFlags(ev, flags) if len(name) == 1 and name.isprintable(): try: Quartz.CGEventKeyboardSetUnicodeString(ev, 1, name) except Exception: pass _mac_post_event(ev) return True except Exception: return False def _main_display_height() -> float: rects = _list_displays() if not rects: return 1080.0 main = min(rects, key=lambda r: (abs(r[0]) + abs(r[1]), -r[2] * r[3])) return float(main[3]) def _mac_mouse_xy() -> tuple[float, float] | None: """Cursor position in Quartz global coords (avoid CGEventGetLocation on arm64).""" if not _IS_MAC: return None try: from AppKit import NSEvent p = NSEvent.mouseLocation() return float(p.x), float(_main_display_height() - p.y) except Exception: return None def _mac_show_cursor() -> None: """Force the OS pointer visible (controlled peer / after remote session).""" try: import Quartz mid = Quartz.CGMainDisplayID() for _ in range(32): Quartz.CGDisplayShowCursor(mid) Quartz.CGDisplayShowCursor(Quartz.kCGNullDirectDisplay) try: from AppKit import NSCursor # type: ignore for _ in range(8): NSCursor.unhide() except Exception: pass except Exception: pass def _mac_virtual_to_cg( vx: float, vy: float, ox: float, oy: float, _desk_h: int ) -> tuple[float, float]: """Virtual desktop (top-left origin, y down) → global screen coords.""" return float(ox + vx), float(oy + vy) def _mac_event_source() -> Any: """HID/session event source — None often drops clicks in background apps.""" try: import Quartz for state in ( Quartz.kCGEventSourceStateHIDSystemState, Quartz.kCGEventSourceStateCombinedSessionState, Quartz.kCGEventSourceStatePrivate, ): try: src = Quartz.CGEventSourceCreate(state) if src is not None: return src except Exception: pass except Exception: pass return None def _mac_inject_mouse_pos(ax: int, ay: int, held_button: int = 0) -> bool: """Inject absolute cursor position on macOS (controlled peer). pynput alone often fails when this process is not the foreground app. usbLAN-style: warp + post MouseMoved on HID/Session taps. When a button is held (``held_button`` in 1/2/3) the move must be posted as the matching *Dragged* event, not MouseMoved — otherwise macOS apps see the button down but treat the motion as a plain hover, so text selection / title-bar / file drags never register. """ try: import Quartz _mac_set_mouse_associated(True) pt = (float(ax), float(ay)) src = _mac_event_source() Quartz.CGWarpMouseCursorPosition(pt) _mac_show_cursor() if held_button == 2: etype = Quartz.kCGEventRightMouseDragged btn_num = Quartz.kCGMouseButtonRight elif held_button == 3: etype = Quartz.kCGEventOtherMouseDragged btn_num = getattr(Quartz, "kCGMouseButtonCenter", 2) elif held_button == 1: etype = Quartz.kCGEventLeftMouseDragged btn_num = Quartz.kCGMouseButtonLeft else: etype = Quartz.kCGEventMouseMoved btn_num = Quartz.kCGMouseButtonLeft ev = Quartz.CGEventCreateMouseEvent(src, etype, pt, btn_num) if ev is not None: _mac_post_event(ev) return True except Exception: return False def _mac_inject_mouse_button( ax: int, ay: int, button: int, pressed: bool, click_state: int = 1 ) -> bool: """Inject mouse button — same direct path as cursor warp (moves already work).""" try: import Quartz from pynput.mouse import Button _mac_set_mouse_associated(True) pt = (float(ax), float(ay)) Quartz.CGWarpMouseCursorPosition(pt) _mac_show_cursor() btn_num = max(0, int(button) - 1) if button == 2: etype = ( Quartz.kCGEventRightMouseDown if pressed else Quartz.kCGEventRightMouseUp ) pbtn = Button.right elif button == 3: etype = ( Quartz.kCGEventOtherMouseDown if pressed else Quartz.kCGEventOtherMouseUp ) pbtn = Button.middle else: etype = ( Quartz.kCGEventLeftMouseDown if pressed else Quartz.kCGEventLeftMouseUp ) pbtn = Button.left ev = Quartz.CGEventCreateMouseEvent(None, etype, pt, btn_num) if ev is not None: click_field = getattr(Quartz, "kCGMouseEventClickState", 2) try: Quartz.CGEventSetIntegerValueField( ev, click_field, max(1, int(click_state)) ) except Exception: pass _mac_post_event(ev) # pynput uses the same HID post — belt-and-suspenders with warp above. try: from pynput.mouse import Controller ctl = Controller() ctl.position = (int(ax), int(ay)) if pressed: ctl.press(pbtn) else: ctl.release(pbtn) except Exception: pass return True except Exception: return False def _win_clip_cursor(rect: tuple[float, float, float, float] | None) -> None: """Confine the Windows cursor to a display rect, or release with None.""" try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 if rect is None: user32.ClipCursor(None) return rx, ry, rw, rh = rect r = wintypes.RECT( int(rx), int(ry), int(rx + rw), int(ry + rh), ) user32.ClipCursor(ctypes.byref(r)) except Exception: pass def _force_release_os_input() -> None: """Best-effort: unstick modifiers/buttons and restore cursor after KM inject. When the controller disconnects while keys are held, Windows/macOS can keep Ctrl/Alt/Shift or mouse buttons 'down', making local KM feel broken even after input-share is turned off. """ if _IS_WIN: try: import ctypes user32 = ctypes.windll.user32 KEYEVENTF_KEYUP = 0x0002 # L/R Shift, Ctrl, Alt, LWin, RWin for vk in (0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x5B, 0x5C, 0x10, 0x11, 0x12): try: user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) except Exception: pass for flag in (0x0004, 0x0010, 0x0040): # LEFTUP RIGHTUP MIDDLEUP try: user32.mouse_event(flag, 0, 0, 0, 0) except Exception: pass try: user32.ClipCursor(None) except Exception: pass try: for _ in range(64): if user32.ShowCursor(True) >= 0: break except Exception: pass except Exception: pass return if _IS_MAC: try: import Quartz Quartz.CGAssociateMouseAndMouseCursorPosition(True) # Release common modifiers via synthetic key-up events for keycode in (0x37, 0x36, 0x3A, 0x3D, 0x3B, 0x3E, 0x38, 0x3C): # cmd/opt/ctrl/shift try: ev = Quartz.CGEventCreateKeyboardEvent(None, keycode, False) if ev is not None: Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev) except Exception: pass for _ in range(64): try: Quartz.CGDisplayShowCursor(Quartz.kCGNullDirectDisplay) except Exception: break try: from AppKit import NSCursor # type: ignore for _ in range(16): NSCursor.unhide() except Exception: pass except Exception: pass def _point_in_rect( x: float, y: float, rect: tuple[float, float, float, float], margin: float = 0.0 ) -> bool: rx, ry, rw, rh = rect return ( rx + margin <= x < rx + rw - margin and ry + margin <= y < ry + rh - margin ) def _key_name(key: Any) -> str: from pynput.keyboard import Key, KeyCode if isinstance(key, KeyCode): if key.char: return key.char if key.vk is not None: return f"vk:{key.vk}" return str(key) if isinstance(key, Key): return key.name or str(key).replace("Key.", "") return str(key) def _key_from_name(name: str) -> Any: from pynput.keyboard import Key, KeyCode if not name: return None if name.startswith("vk:"): try: return KeyCode.from_vk(int(name.split(":", 1)[1])) except Exception: return None if len(name) == 1: return KeyCode.from_char(name) try: return Key[name] except KeyError: # aliases aliases = { "cmd": "cmd", "command": "cmd", "windows": "cmd", "return": "enter", "esc": "esc", "escape": "esc", } alt = aliases.get(name) if alt: try: return Key[alt] except KeyError: pass return None _MAC_MOD_KEYCODES: dict[int, str] = { 54: "cmd_r", 55: "cmd_l", 56: "shift", 60: "shift", 58: "alt_l", 61: "alt_r", 59: "ctrl", 62: "ctrl", 57: "caps_lock", } # Mac ANSI virtual keycodes → canonical, platform-neutral names. # Sending "vk:" broke Windows peers, which read the number as a # Windows VK (e.g. mac keycode 1 = 's' but VK 1 = left mouse button). Names here # are understood by both _win_vk_from_name and _mac_vk_from_name. _MAC_KEYCODE_TO_NAME: dict[int, str] = { 0: "a", 1: "s", 2: "d", 3: "f", 4: "h", 5: "g", 6: "z", 7: "x", 8: "c", 9: "v", 11: "b", 12: "q", 13: "w", 14: "e", 15: "r", 16: "y", 17: "t", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6", 23: "5", 24: "=", 25: "9", 26: "7", 27: "-", 28: "8", 29: "0", 30: "]", 31: "o", 32: "u", 33: "[", 34: "i", 35: "p", 37: "l", 38: "j", 39: "'", 40: "k", 41: ";", 42: "\\", 43: ",", 44: "/", 45: "n", 46: "m", 47: ".", 50: "`", 36: "enter", 48: "tab", 49: "space", 51: "backspace", 53: "esc", 117: "delete", 115: "home", 116: "page_up", 119: "end", 121: "page_down", 123: "left", 124: "right", 125: "down", 126: "up", 122: "f1", 120: "f2", 99: "f3", 118: "f4", 96: "f5", 97: "f6", 98: "f7", 100: "f8", 101: "f9", 109: "f10", 103: "f11", 111: "f12", 76: "enter", 71: "esc", # Korean/Japanese input-source keys on Apple keyboards. The 한/A key maps to # Windows VK_HANGUL and 한자 to VK_HANJA so IME toggle reaches a Win peer. 104: "hangul", 102: "hangul", 110: "hanja", } # Keys that must always be forwarded by name (never as a typed character). _MAC_NAMED_KEYS = frozenset( { "enter", "tab", "space", "backspace", "esc", "delete", "home", "end", "page_up", "page_down", "left", "right", "up", "down", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", "hangul", "hanja", } ) def _mac_key_from_cgevent(event: Any, etype: int) -> tuple[str, bool] | None: """Map a CGEvent key event to (name, pressed) for USB forwarding.""" try: import Quartz except Exception: return None if etype == Quartz.kCGEventFlagsChanged: kc = int( Quartz.CGEventGetIntegerValueField(event, Quartz.kCGKeyboardEventKeycode) ) name = _MAC_MOD_KEYCODES.get(kc) if not name: return None flags = int(Quartz.CGEventGetFlags(event)) flag_masks = { 54: Quartz.kCGEventFlagMaskCommand, 55: Quartz.kCGEventFlagMaskCommand, 56: Quartz.kCGEventFlagMaskShift, 60: Quartz.kCGEventFlagMaskShift, 58: Quartz.kCGEventFlagMaskAlternate, 61: Quartz.kCGEventFlagMaskAlternate, 59: Quartz.kCGEventFlagMaskControl, 62: Quartz.kCGEventFlagMaskControl, 57: Quartz.kCGEventFlagMaskAlphaShift, } mask = flag_masks.get(kc, 0) return name, bool(flags & mask) if etype not in (Quartz.kCGEventKeyDown, Quartz.kCGEventKeyUp): return None pressed = etype == Quartz.kCGEventKeyDown kc = int( Quartz.CGEventGetIntegerValueField(event, Quartz.kCGKeyboardEventKeycode) ) named = _MAC_KEYCODE_TO_NAME.get(kc) # Navigation / editing / function keys: always forward by canonical name. if named in _MAC_NAMED_KEYS: return named, pressed # Text keys: prefer the produced character so Shift/AltGr/IME layouts work. try: import ctypes buf = (ctypes.c_uint16 * 8)() n = ctypes.c_uint32(0) Quartz.CGEventKeyboardGetUnicodeString( event, 8, buf, ctypes.byref(n) ) if int(n.value) > 0: text = "".join(chr(int(buf[i])) for i in range(int(n.value))) if len(text) == 1 and text.isprintable(): return text, pressed except Exception: pass # No printable char (e.g. Ctrl/Cmd held) — fall back to the canonical name # so the peer receives "s" rather than an unusable "vk:1". if named: return named, pressed return f"vk:{kc}", pressed _WIN_VK_NAMES: dict[int, str] = { 0x08: "backspace", 0x09: "tab", 0x0D: "enter", 0x1B: "esc", 0x20: "space", 0x21: "page_up", 0x22: "page_down", 0x23: "end", 0x24: "home", 0x25: "left", 0x26: "up", 0x27: "right", 0x28: "down", 0x2D: "insert", 0x2E: "delete", 0x14: "caps_lock", # VK_CAPITAL — Caps Lock (대소문자) toggle 0x15: "hangul", # VK_HANGUL / VK_KANA — Korean/English IME toggle 0x19: "hanja", # VK_HANJA / VK_KANJI 0x5B: "cmd", 0x5C: "cmd", 0xA0: "shift", 0xA1: "shift", 0xA2: "ctrl", 0xA3: "ctrl", 0xA4: "alt", 0xA5: "alt", } def _win_vk_to_name(vk: int) -> str: vk = int(vk) & 0xFF if 0x30 <= vk <= 0x39: return chr(vk) if 0x41 <= vk <= 0x5A: return chr(vk).lower() if 0x60 <= vk <= 0x69: return f"f{vk - 0x60}" if 0x70 <= vk <= 0x87: return f"f{vk - 0x6F}" return _WIN_VK_NAMES.get(vk, f"vk:{vk}") def _win_vk_from_name(name: str) -> int | None: if not name: return None low = name.lower() for vk, n in _WIN_VK_NAMES.items(): if n == low: return vk if name.startswith("vk:"): try: return int(name.split(":", 1)[1]) & 0xFFFF except Exception: return None if len(name) == 1: ch = name.upper() if "A" <= ch <= "Z": return ord(ch) if "0" <= ch <= "9": return ord(ch) if low.startswith("f") and low[1:].isdigit(): n = int(low[1:]) if 1 <= n <= 12: return 0x6F + n if 13 <= n <= 24: return 0x60 + (n - 12) return None def _win_attach_foreground(user32: Any) -> tuple[int, int] | None: """Attach our thread to the foreground window's input queue (better key inject).""" import ctypes hwnd = int(user32.GetForegroundWindow() or 0) if not hwnd: return None pid = ctypes.c_ulong(0) tid = int(user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) or 0) our = int(ctypes.windll.kernel32.GetCurrentThreadId()) if not tid or tid == our: return (hwnd, 0) if not user32.AttachThreadInput(our, tid, True): return (hwnd, 0) return (hwnd, tid) def _win_detach_foreground(user32: Any, tid: int) -> None: import ctypes if tid: our = int(ctypes.windll.kernel32.GetCurrentThreadId()) user32.AttachThreadInput(our, tid, False) def _win_ime_open_status(user32: Any, hwnd: int) -> int | None: """Return IME open status (1=Hangul-ish open, 0=closed) or None if unknown.""" import ctypes if not hwnd: return None imm32 = ctypes.windll.imm32 WM_IME_CONTROL = 0x0283 IMC_GETOPENSTATUS = 0x0005 hime = imm32.ImmGetDefaultIMEWnd(hwnd) if not hime: return None return int(user32.SendMessageW(hime, WM_IME_CONTROL, IMC_GETOPENSTATUS, 0)) def _win_toggle_caps_lock(user32: Any) -> bool: """Flip Caps Lock so GetKeyState(VK_CAPITAL) actually changes. Plain SendInput(VK_CAPITAL) often only blinks the indicator. The classic keybd_event pattern (scan 0x45 + KEYEVENTF_EXTENDEDKEY) is what apps respect. """ import ctypes from ctypes import wintypes KEYEVENTF_EXTENDEDKEY = 0x0001 KEYEVENTF_KEYUP = 0x0002 KEYEVENTF_SCANCODE = 0x0008 VK_CAPITAL = 0x14 attached = _win_attach_foreground(user32) tid = attached[1] if attached else 0 try: before = int(user32.GetKeyState(VK_CAPITAL)) & 1 user32.keybd_event(VK_CAPITAL, 0x45, KEYEVENTF_EXTENDEDKEY, 0) time.sleep(0.04) user32.keybd_event( VK_CAPITAL, 0x45, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP, 0 ) time.sleep(0.03) after = int(user32.GetKeyState(VK_CAPITAL)) & 1 if before != after: return True ULONG_PTR = ( ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong ) class KEYBDINPUT(ctypes.Structure): _fields_ = [ ("wVk", wintypes.WORD), ("wScan", wintypes.WORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR), ] class MOUSEINPUT(ctypes.Structure): _fields_ = [ ("dx", wintypes.LONG), ("dy", wintypes.LONG), ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR), ] class INPUT(ctypes.Structure): class _I(ctypes.Union): _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)] _anonymous_ = ("i",) _fields_ = [("type", wintypes.DWORD), ("i", _I)] for up in (False, True): flags = KEYEVENTF_SCANCODE | (KEYEVENTF_KEYUP if up else 0) inp = INPUT() inp.type = 1 inp.ki = KEYBDINPUT(0, 0x3A, flags, 0, 0) user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) if not up: time.sleep(0.04) return True finally: _win_detach_foreground(user32, tid) def _win_pulse_vk( user32: Any, vk: int, scan: int = 0, *, extended: bool = False ) -> None: KEYEVENTF_EXTENDEDKEY = 0x0001 KEYEVENTF_KEYUP = 0x0002 flags = KEYEVENTF_EXTENDEDKEY if extended else 0 user32.keybd_event(vk, scan, flags, 0) time.sleep(0.04) user32.keybd_event(vk, scan, flags | KEYEVENTF_KEYUP, 0) def _win_toggle_hangul(user32: Any) -> bool: """Toggle Korean Hangul/English — exactly ONE key pulse. Win11 Microsoft IME (TSF) often ignores Imm* and bare SendInput(VK_HANGUL). Attach to the foreground input queue and fire one Hangul key (scan 0xF2); if IME open status is readable and still unchanged, try Right Alt once. """ attached = _win_attach_foreground(user32) hwnd = attached[0] if attached else int(user32.GetForegroundWindow() or 0) tid = attached[1] if attached else 0 try: before = _win_ime_open_status(user32, hwnd) # VK_HANGUL + Korean-keyboard scan 0xF2 _win_pulse_vk(user32, 0x15, 0xF2) after = _win_ime_open_status(user32, hwnd) if before is None or after is None: # Status unknown — do not stack a second key (risk of net-zero). return True if before != after: return True # Status readable and unchanged — Right Alt (common KR Hangul key) _win_pulse_vk(user32, 0xA5, 0x38, extended=True) return True finally: _win_detach_foreground(user32, tid) def _win_inject_key(name: str, pressed: bool, active_mods: set[str]) -> bool: """Inject keyboard on Windows — Unicode for IME/Hangul, VK for specials.""" try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 KEYEVENTF_KEYUP = 0x0002 KEYEVENTF_UNICODE = 0x0004 ULONG_PTR = ( ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong ) class MOUSEINPUT(ctypes.Structure): _fields_ = [ ("dx", wintypes.LONG), ("dy", wintypes.LONG), ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR), ] class KEYBDINPUT(ctypes.Structure): _fields_ = [ ("wVk", wintypes.WORD), ("wScan", wintypes.WORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR), ] class INPUT(ctypes.Structure): # The union MUST carry MOUSEINPUT so sizeof(INPUT) matches the real # Win32 INPUT (40 bytes on x64). With only KEYBDINPUT the struct is # 32 bytes, so SendInput sees cbSize != sizeof(INPUT) and fails # silently (returns 0, no exception) — keystrokes never land. class _I(ctypes.Union): _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)] _anonymous_ = ("i",) _fields_ = [("type", wintypes.DWORD), ("i", _I)] user32.SendInput.argtypes = [ wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int, ] user32.SendInput.restype = wintypes.UINT def _send(inp: INPUT) -> None: sent = user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) if sent != 1: raise OSError(f"SendInput rejected input (sent={sent})") def _send_vk(vk: int, scan: int, extended: bool, up: bool) -> None: KEYEVENTF_EXTENDEDKEY = 0x0001 flags = 0 if extended: flags |= KEYEVENTF_EXTENDEDKEY if up: flags |= KEYEVENTF_KEYUP inp = INPUT() inp.type = 1 inp.ki = KEYBDINPUT(vk, scan, flags, 0, 0) _send(inp) # Caps Lock / 한·영 / 한자: do NOT rely on SendInput alone. # - Caps Lock: classic keybd_event(VK_CAPITAL, 0x45, EXTENDEDKEY) is the # MSDN pattern that actually flips GetKeyState (plain SendInput often # only blinks the indicator). # - Hangul: Microsoft IME ignores injected VK_HANGUL unless the IME # conversion/open status is toggled via imm32. # Key-up frames are no-ops so one Mac pulse = one Windows toggle. low = name.lower() if low in ("caps_lock", "hangul", "hanja"): if not pressed: return True if low == "caps_lock": return _win_toggle_caps_lock(user32) if low == "hangul": return _win_toggle_hangul(user32) # hanja — best-effort VK pulse _send_vk(0x19, 0x71, False, up=False) time.sleep(0.03) _send_vk(0x19, 0x71, False, up=True) return True # Shortcut combos (Ctrl/Alt/Win + key) must go through VK codes so the # modifier actually combines. Unicode injection ignores modifier state, # so Ctrl+C etc. would type a character instead of firing the shortcut. combo_active = bool( active_mods & { "ctrl", "ctrl_l", "ctrl_r", "control", "alt", "alt_l", "alt_r", "option", "alt_gr", "cmd", "cmd_l", "cmd_r", "command", "super", "windows", "win", } ) if len(name) == 1 and name.isprintable() and not combo_active: if not pressed: return True code = ord(name) for up in (False, True): inp = INPUT() inp.type = 1 inp.ki = KEYBDINPUT( 0, code, KEYEVENTF_UNICODE | (KEYEVENTF_KEYUP if up else 0), 0, 0, ) _send(inp) return True vk = _win_vk_from_name(name) if vk is None and len(name) == 1 and name.isprintable(): # Printable char under a modifier but no VK mapping — fall back to # Unicode so at least the character is delivered. if not pressed: return True code = ord(name) for up in (False, True): inp = INPUT() inp.type = 1 inp.ki = KEYBDINPUT( 0, code, KEYEVENTF_UNICODE | (KEYEVENTF_KEYUP if up else 0), 0, 0, ) _send(inp) return True if vk is None: return False flags = KEYEVENTF_KEYUP if not pressed else 0 inp = INPUT() inp.type = 1 inp.ki = KEYBDINPUT(vk, 0, flags, 0, 0) _send(inp) return True except Exception: return False class InputShare: """Edge-triggered KM handoff bound to a live Session.""" def __init__(self, on_log: Optional[Any] = None) -> None: self._log = on_log or (lambda m: None) self._lock = threading.RLock() self.session: Session | None = None self.enabled = False self.trusted = False self.controlling_remote = False self.being_controlled = False self._local_w, self._local_h = _screen_size() self._remote_w, self._remote_h = 1920, 1080 self._vx = 0.0 self._vy = 0.0 self._desk_ox = 0.0 self._desk_oy = 0.0 self._edge_since: float | None = None self._handoff_until = 0.0 self._inject_release_until = 0.0 self._watch_stop = threading.Event() self._watch_thread: threading.Thread | None = None self._toggle_listener = None self._toggle_ctrl_down = False self._toggle_last_at = 0.0 self._mouse_listener = None self._key_listener = None self._mac_tap = None self._mac_tap_source = None self._mac_tap_thread: threading.Thread | None = None self._mac_tap_callback = None self._mac_click_state = 0 self._click_sent = 0 self._click_anchor: tuple[int, int] | None = None self._peer_click_recv = 0 self._left_return_since: float | None = None self._peer_left_return_since: float | None = None self._local_reclaim_since: float | None = None self._win_mouse_hook = None self._win_mouse_proc = None self._win_kbd_proc = None self._win_hook_thread: threading.Thread | None = None self._kb_from_hook = False self._mouse_ctl = None self._key_ctl = None self._suppressing = False self._injecting = False self._warping = False self._grab_center: tuple[int, int] = (0, 0) self._grab_rect: tuple[float, float, float, float] | None = None self._move_from_tap = False self._delta_capture = False self._soft_grab = False self._status = "꺼짐" self.howto = "" self.platform = "windows" if _IS_WIN else ("macos" if _IS_MAC else "other") self._cursor_hidden = False self._debug_edge_log_at = 0.0 self._cap_stop = threading.Event() self._cap_thread: threading.Thread | None = None self._remote_acked = False self._capture_armed = False self._move_sent = 0 self._ack_gen = 0 self._delta_capture = False # A single Mac Caps Lock tap can emit two flags-changed events (ON then # OFF); coalesce them so Windows receives one toggle, not two (net zero). self._last_caps_at = 0.0 # Modifier tracking for paste intercept self._mod_cmd = False self._mod_ctrl = False self._mod_shift = False self._mod_alt = False # Diagnostics: count keys forwarded to / injected from the peer self._key_sent_count = 0 self._key_recv_count = 0 # Keys/buttons held via peer inject — must release on handoff/disable self._injected_keys_down: set[str] = set() self._injected_buttons_down: set[int] = set() self._peer_modifiers: set[str] = set() # Callback: () -> dict-like result from start_clipboard_send self.on_clipboard_paste: Optional[Any] = None # Callback: () -> None — Esc while KM capture: disable share + quit app self.on_escape_quit: Optional[Any] = None def bind_session(self, session: Session | None) -> None: share_on = False lw = lh = 0 with self._lock: if self.session is session: return self._unbind_session() self.session = session if session is not None: session.on_input_ctrl = self._on_peer_ctrl session.on_input_move = self._on_peer_move session.on_input_button = self._on_peer_button session.on_input_wheel = self._on_peer_wheel session.on_input_key = self._on_peer_key session.on_input_clipboard = self._on_peer_clipboard share_on = self.enabled if share_on: self._local_size() lw, lh = self._local_w, self._local_h if session is not None and share_on: try: session.send_input_ctrl(INPUT_CTRL_SHARE_ON, lw, lh) except Exception as exc: self._log(f"입력공유 동기화 전송 실패: {exc}") def _unbind_session(self) -> None: if self.session is not None: self.session.on_input_ctrl = None self.session.on_input_move = None self.session.on_input_button = None self.session.on_input_wheel = None self.session.on_input_key = None self.session.on_input_clipboard = None self.session = None def status_dict(self) -> dict[str, Any]: with self._lock: peer_ready = self.session is not None and self.session._connected.is_set() return { "supported": True, "enabled": self.enabled, "trusted": self.trusted, "peer": peer_ready, "controlling_remote": self.controlling_remote, "being_controlled": self.being_controlled, "primary_ready": peer_ready, "remote_acked": self._remote_acked, "capture_armed": self._capture_armed, "move_sent": self._move_sent, "status": self._status, "howto": self.howto, "platform": self.platform, "move_from_tap": bool(getattr(self, "_move_from_tap", False)), "soft_grab": bool(getattr(self, "_soft_grab", False)), "mac_tap_kind": getattr(self, "_mac_tap_kind", None), "build": INPUTSHARE_BUILD, "click_sent": self._click_sent, "click_recv": self._peer_click_recv, "accessibility": _mac_accessibility_ok() if _IS_MAC else None, "input_monitoring": ( _mac_input_monitoring_ok() if _IS_MAC else None ), "ok": True, } def prompt_trust(self) -> dict[str, Any]: self._ensure_controllers() info = self.status_dict() if _IS_MAC: info["howto"] = ( "시스템 설정 → 개인정보 보호 및 보안 에서 " "① 손쉬운 사용(마우스·클릭) ② 입력 모니터링(키보드) 두 곳 모두 " "Terminal/Python/JUC500 앱을 허용한 뒤 앱을 재시작하세요." ) info["message"] = info["howto"] if not _mac_input_monitoring_ok(): _mac_request_input_monitoring() elif _IS_WIN: info["howto"] = ( "Windows에서 입력공유는 관리자 권한 없이 동작합니다. " "일부 보안 소프트웨어가 훅을 막으면 예외를 추가하세요." ) info["message"] = info["howto"] else: info["howto"] = "이 OS에서는 입력공유가 제한될 수 있습니다." info["message"] = info["howto"] self.howto = info["howto"] return info def set_enabled(self, enabled: bool, *, from_peer: bool = False) -> dict[str, Any]: sync_peer = False sync_on = False lw = lh = 0 with self._lock: session_ok = ( self.session is not None and self.session._connected.is_set() ) if enabled and self.enabled and from_peer: return self.status_dict() if not enabled and not self.enabled and from_peer: return self.status_dict() if enabled: if not session_ok: return { **self.status_dict(), "ok": False, "error": "먼저 USB 「연결」을 완료하세요.", } if from_peer and _IS_MAC: _mac_request_accessibility() ok, err = self._ensure_controllers() if not ok: self.enabled = False self.trusted = False self._status = "권한 필요" if from_peer: self._log( f"상대는 입력공유 ON — 이 PC 권한 부족: {err}" ) return {**self.status_dict(), "ok": False, "error": err} self.enabled = True self.trusted = True self._status = "이 PC 제어" self._edge_since = None self._handoff_until = time.monotonic() + EDGE_COOLDOWN_S self._local_size() lw, lh = self._local_w, self._local_h rects = _list_displays() self._start_watch() self._start_toggle_hotkey_locked() if from_peer: self._log("상대와 입력공유 동기화 — 켜짐") else: self._log( "입력공유 켜짐 — 모니터 오른쪽 끝(옆 모니터 없을 때) → 상대 PC / " "상대 왼쪽 끝 → 이 PC / Ctrl+R → 제어권 토글 / Esc → 끄고 종료" ) if _IS_MAC: _mac_request_accessibility() self._log( f"화면 {self._local_w}x{self._local_h}, 디스플레이 {len(rects)}개" ) sync_peer = not from_peer and session_ok sync_on = True else: self._disable_locked() if from_peer: self._log("상대와 입력공유 동기화 — 꺼짐") else: self._log("입력공유 꺼짐") sync_peer = not from_peer and session_ok sync_on = False result = self.status_dict() if sync_peer and self.session is not None: try: if sync_on: self.session.send_input_ctrl(INPUT_CTRL_SHARE_ON, lw, lh) else: self.session.send_input_ctrl(INPUT_CTRL_SHARE_OFF, 0, 0) except Exception as exc: self._log(f"상대 입력공유 동기화 전송 실패: {exc}") return result def set_control(self, target: str) -> dict[str, Any]: with self._lock: if not self.enabled: return { **self.status_dict(), "ok": False, "error": "먼저 「입력공유」를 켜세요.", } if self.session is None or not self.session._connected.is_set(): return { **self.status_dict(), "ok": False, "error": "먼저 USB 「연결」을 완료하세요.", } if target == "remote": if self.being_controlled: # Take over: stop being controlled, start controlling peer self._stop_being_controlled_locked() self._take_remote_locked() else: if self.controlling_remote: self._release_local_locked() elif self.being_controlled: self._stop_being_controlled_locked() if self.session: self.session.send_input_ctrl(INPUT_CTRL_LOCAL, *self._local_size()) return {**self.status_dict(), "ok": True} def ensure_local_input_restored(self, *, notify_peer: bool = True) -> None: """Idempotent: drop KM roles and always restore OS mouse/keyboard. Must run on normal exit, signal exit, disconnect, and GUI start so a force-killed previous process' sticky modifiers can also be cleared. """ with self._lock: was_controlling = self.controlling_remote if notify_peer and was_controlling and self.session is not None: try: self._send_modifier_keyup_to_peer() except Exception: pass try: self.session.send_input_ctrl(INPUT_CTRL_LOCAL, *self._local_size()) except Exception: pass self.enabled = False self._ack_gen += 1 self._stop_watch() self._stop_toggle_hotkey_locked() self._stop_capture_locked() self._stop_being_controlled_locked() self.controlling_remote = False self.being_controlled = False self._remote_acked = False self._capture_armed = False self._clear_injected_input_locked() _force_release_os_input() self._show_local_cursor() self._status = "꺼짐" def recover_local_input(self) -> dict[str, Any]: """Emergency unstick: stop KM roles and force-release OS modifiers/cursor.""" self.ensure_local_input_restored(notify_peer=True) self._log("로컬 마우스·키보드 강제 복구 실행") return {**self.status_dict(), "ok": True, "recovered": True} def on_disconnect(self) -> None: self.ensure_local_input_restored(notify_peer=False) with self._lock: self._unbind_session() def _disable_locked(self) -> None: was_controlling = self.controlling_remote # Tell peer to drop "being controlled" + release any stuck remote keys. if was_controlling and self.session is not None: try: self._send_modifier_keyup_to_peer() except Exception: pass try: self.session.send_input_ctrl(INPUT_CTRL_LOCAL, *self._local_size()) except Exception: pass self.enabled = False self._ack_gen += 1 self._stop_watch() self._stop_toggle_hotkey_locked() self._stop_capture_locked() self._stop_being_controlled_locked() self.controlling_remote = False self.being_controlled = False self._remote_acked = False self._capture_armed = False self._clear_injected_input_locked() self._show_local_cursor() _force_release_os_input() self._status = "꺼짐" self._log("입력 상태 복구 — 커서/수정키 강제 해제") def _send_modifier_keyup_to_peer(self) -> None: if self.session is None: return for name in ( "ctrl", "ctrl_l", "ctrl_r", "alt", "alt_l", "alt_r", "shift", "shift_l", "shift_r", "cmd", "cmd_l", "cmd_r", "super", "windows", ): try: self.session.send_input_key(name, False) except Exception: pass for button in (1, 2, 3): try: self.session.send_input_button(button, False) except Exception: pass def _clear_injected_input_locked(self) -> None: """Release keys/buttons we synthetically pressed on this PC.""" keys = list(self._injected_keys_down) buttons = list(self._injected_buttons_down) self._injected_keys_down.clear() self._injected_buttons_down.clear() self._peer_modifiers.clear() self._mod_cmd = self._mod_ctrl = self._mod_shift = self._mod_alt = False if self._key_ctl is not None: for name in keys: mapped = name if _IS_WIN and name.lower() in ( "cmd", "cmd_l", "cmd_r", "command", "super", "windows", "win", ): mapped = "ctrl" key = _key_from_name(mapped) if key is None: continue try: self._key_ctl.release(key) except Exception: pass # Always poke common modifiers even if tracking missed them for name in ("ctrl", "alt", "shift", "cmd"): key = _key_from_name(name) if key is None: continue try: self._key_ctl.release(key) except Exception: pass for button in (1, 2, 3): try: self._inject_mouse_button(button, False) except Exception: pass try: # Restore cursor visibility before clearing the flag if self._cursor_hidden: self._show_local_cursor() except Exception: self._cursor_hidden = False _force_release_os_input() def _local_size(self) -> tuple[int, int]: ox, oy, w, h = _virtual_desktop() self._desk_ox, self._desk_oy = ox, oy self._local_w, self._local_h = w, h return self._local_w, self._local_h def _ensure_controllers(self) -> tuple[bool, str]: try: from pynput.keyboard import Controller as KeyController from pynput.mouse import Controller as MouseController if self._mouse_ctl is None: self._mouse_ctl = MouseController() if self._key_ctl is None: self._key_ctl = KeyController() # smoke: read position _ = self._mouse_ctl.position self.trusted = True return True, "" except Exception as exc: self.trusted = False if _IS_MAC: msg = ( "macOS 손쉬운 사용(Accessibility) 권한이 필요합니다. " f"({exc})" ) else: msg = f"입력 장치를 열 수 없습니다: {exc}" self.howto = msg return False, msg def _start_watch(self) -> None: self._stop_watch() self._watch_stop.clear() self._watch_thread = threading.Thread( target=self._watch_loop, name="juc500-input-edge", daemon=True ) self._watch_thread.start() def _stop_watch(self) -> None: self._watch_stop.set() t = self._watch_thread self._watch_thread = None if t and t.is_alive(): t.join(timeout=1.0) def _start_toggle_hotkey_locked(self) -> None: """Watch Ctrl+R while local; remote capture handles the chord itself.""" if self._toggle_listener is not None: return try: from pynput.keyboard import Listener self._toggle_ctrl_down = False listener = Listener( on_press=lambda key: self._on_toggle_hotkey_key(key, True), on_release=lambda key: self._on_toggle_hotkey_key(key, False), suppress=False, ) self._toggle_listener = listener listener.start() except Exception as exc: self._toggle_listener = None self._log(f"Ctrl+R 제어권 단축키 등록 실패: {exc}") def _stop_toggle_hotkey_locked(self) -> None: listener = self._toggle_listener self._toggle_listener = None self._toggle_ctrl_down = False if listener is not None: try: listener.stop() except Exception: pass def _on_toggle_hotkey_key(self, key: Any, pressed: bool) -> None: """Handle physical Ctrl+R while this PC currently owns local input.""" name = _key_name(key).lower() with self._lock: if name in ("ctrl", "ctrl_l", "ctrl_r", "control"): self._toggle_ctrl_down = bool(pressed) return # During remote capture the native tap/hook handles the chord. if ( not pressed or name not in ("r", "vk:82", "vk:15") or not self._toggle_ctrl_down or self.controlling_remote or self.being_controlled ): return self._trigger_control_toggle() def _trigger_control_toggle(self) -> None: """Debounced Ctrl+R toggle; safe from keyboard tap/hook threads.""" with self._lock: now = time.monotonic() if now - self._toggle_last_at < 0.6: return if ( not self.enabled or self.session is None or not self.session._connected.is_set() ): return self._toggle_last_at = now target = "local" if self.controlling_remote else "remote" def _switch() -> None: result = self.set_control(target) if result.get("ok"): self._log( f"Ctrl+R — 제어권 토글 → " f"{'이 PC' if target == 'local' else '상대 PC'}" ) else: self._log( f"Ctrl+R 제어권 전환 실패: {result.get('error', '알 수 없음')}" ) threading.Thread( target=_switch, name="juc500-ctrl-r-toggle", daemon=True ).start() def _watch_loop(self) -> None: while not self._watch_stop.is_set(): try: with self._lock: now = time.monotonic() cooling = now < self._handoff_until enabled = self.enabled cr = self.controlling_remote bc = self.being_controlled ctl = self._mouse_ctl if enabled and not cooling and ctl is not None and not cr and not bc: x, y = ctl.position fx, fy = float(x), float(y) edge_x = _right_handoff_x(fx, fy) near = edge_x is not None and fx >= edge_x - EDGE_PX if near: if self._edge_since is None: self._edge_since = time.monotonic() if time.monotonic() >= self._debug_edge_log_at: self._debug_edge_log_at = time.monotonic() + 2.0 self._log( f"가장자리 감지 x={fx:.0f}/{edge_x:.0f} — 상대 PC로 전환 중…" ) elif time.monotonic() - self._edge_since >= EDGE_DWELL_S: with self._lock: if ( self.enabled and not self.controlling_remote and not self.being_controlled and time.monotonic() >= self._handoff_until ): self._take_remote_locked() self._edge_since = None else: self._edge_since = None elif enabled and bc and not cooling and ctl is not None: x, y = ctl.position fx, fy = float(x), float(y) left_x = _left_reclaim_x(fx, fy) near = left_x is not None and fx <= left_x + RETURN_EDGE_PX if near: if self._local_reclaim_since is None: self._local_reclaim_since = time.monotonic() elif time.monotonic() - self._local_reclaim_since >= RETURN_EDGE_DWELL_S: with self._lock: if self.being_controlled: w, h = self._local_w, self._local_h self._stop_being_controlled_locked() self._status = "이 PC 제어" if self.enabled else "꺼짐" session = self.session if session: try: session.send_input_ctrl(INPUT_CTRL_LOCAL, w, h) except Exception: pass self._log( f"로컬 왼쪽 가장자리 — 제어권 회수 " f"(x≤{RETURN_EDGE_PX}px)" ) _force_release_os_input() self._local_reclaim_since = None else: self._local_reclaim_since = None else: self._edge_since = None self._local_reclaim_since = None except Exception: pass self._watch_stop.wait(0.02) def _arm_cooldown(self) -> None: self._handoff_until = time.monotonic() + EDGE_COOLDOWN_S self._edge_since = None self._left_return_since = None self._peer_left_return_since = None self._local_reclaim_since = None def _should_return_from_remote_left(self, vx: float, now: float) -> bool: """Virtual cursor in left return strip long enough → release to local.""" edge = float(RETURN_EDGE_PX) if vx > edge or now < self._handoff_until: self._left_return_since = None return False if self._left_return_since is None: self._left_return_since = now return False if now - self._left_return_since >= RETURN_EDGE_DWELL_S: self._left_return_since = None return True return False def _should_peer_return_left(self, ax: float, now: float) -> bool: """Peer being_controlled: left-edge dwell → request LOCAL.""" edge = float(RETURN_EDGE_PX) if ax > edge or now < self._handoff_until: self._peer_left_return_since = None return False if self._peer_left_return_since is None: self._peer_left_return_since = now return False if now - self._peer_left_return_since >= RETURN_EDGE_DWELL_S: self._peer_left_return_since = None return True return False def _take_remote_locked(self) -> None: if self.session is None: return self._local_size() self._remote_acked = False self._capture_armed = False self._move_sent = 0 self.controlling_remote = True self.being_controlled = False self._mod_cmd = False self._mod_ctrl = False self._mod_shift = False self._mod_alt = False # Enter remote from the left side (as if coming from this PC on the left) self._vx = float(REMOTE_ENTER_INSET) self._vy = float(self._remote_h) / 2.0 self._status = "상대 응답 대기" self._arm_cooldown() self._ack_gen += 1 gen = self._ack_gen self._log("제어권 요청 → 상대 PC (상대 수락 후 커서 전달 시작)") # Tell peer; do NOT hide/suppress until ACK — otherwise dead cursor + stuck UI. try: self.session.send_input_ctrl(INPUT_CTRL_REMOTE, self._local_w, self._local_h) except Exception as exc: self._log(f"제어권 전송 실패: {exc}") self.controlling_remote = False self._status = "이 PC 제어" if self.enabled else "꺼짐" return threading.Thread( target=self._await_remote_ack, args=(gen,), name="juc500-km-ack", daemon=True ).start() def _await_remote_ack(self, gen: int) -> None: deadline = time.monotonic() + REMOTE_ACK_TIMEOUT_S while time.monotonic() < deadline: time.sleep(0.04) with self._lock: if gen != self._ack_gen: return if not self.controlling_remote: return if self._remote_acked: if not self._capture_armed: self._arm_capture_after_ack_locked() return with self._lock: if gen != self._ack_gen: return if self.controlling_remote and not self._remote_acked: self._log( "상대가 수락하지 않음 — 양쪽 「입력공유」 켜기·손쉬운 사용 권한 확인 후 재시도" ) self._release_local_locked() def _arm_capture_after_ack_locked(self) -> None: if self._capture_armed or not self.controlling_remote: return self._capture_armed = True self._status = "상대 PC 제어" self._log("상대 수락 — 움직임·클릭을 상대 PC로 전달합니다 (왼쪽 끝 = 복귀)") self._start_capture_locked() def _apply_remote_ack_locked(self, width: int, height: int) -> None: if width > 0 and height > 0: self._remote_w, self._remote_h = width, height self._remote_acked = True self._log(f"상대 화면 크기 수신: {self._remote_w}x{self._remote_h}") if not self._capture_armed: self._arm_capture_after_ack_locked() def _release_local_locked(self) -> None: was = self.controlling_remote was_armed = self._capture_armed had_ack = self._remote_acked move_n = self._move_sent # Invalidate in-flight ACK waiter so it cannot start capture after release self._ack_gen += 1 # Clear controlling FIRST so in-flight tap/hook/key callbacks pass through # while listeners are still shutting down (otherwise local clicks stay dead). self.controlling_remote = False self._suppressing = False self._remote_acked = False self._capture_armed = False if was and self.session: try: self._send_modifier_keyup_to_peer() except Exception: pass self._stop_capture_locked() self._status = "이 PC 제어" if self.enabled else "꺼짐" self._arm_cooldown() if was and self.session: try: self.session.send_input_ctrl(INPUT_CTRL_LOCAL, *self._local_size()) except Exception: pass if was_armed: self._log( f"제어권 → 이 PC (전송 이동 {move_n}회" f"{', 상대 응답 OK' if had_ack else ''})" ) else: self._log("제어권 → 이 PC (상대 미수락 또는 취소)") try: if self._mouse_ctl: # Always return near primary (or current) display right edge rects = _list_displays() primary = min(rects, key=lambda r: (abs(r[0]) + abs(r[1]), -r[2] * r[3])) rx, ry, rw, rh = primary for cand in rects: if _has_neighbor_right(cand, cand[1] + cand[3] / 2): continue if cand[0] + cand[2] >= rx + rw: rx, ry, rw, rh = cand px = int(rx + rw - LOCAL_RETURN_INSET) py = int(ry + rh / 2) self._warp_mouse(px, py) except Exception: pass self._show_local_cursor() _force_release_os_input() def _hide_local_cursor(self) -> None: """Hide the local OS pointer once (refcount-safe).""" if self._cursor_hidden: return try: if _IS_MAC: import Quartz Quartz.CGDisplayHideCursor(Quartz.kCGNullDirectDisplay) try: from AppKit import NSCursor # type: ignore NSCursor.hide() except Exception: pass elif _IS_WIN: import ctypes user32 = ctypes.windll.user32 # One step down; avoid stacking dozens during capture if user32.ShowCursor(False) >= 0: user32.ShowCursor(False) self._cursor_hidden = True except Exception as exc: self._log(f"커서 숨김 실패: {exc}") def _show_local_cursor(self) -> None: try: if _IS_MAC: import Quartz for _ in range(64): Quartz.CGDisplayShowCursor(Quartz.kCGNullDirectDisplay) try: from AppKit import NSCursor # type: ignore for _ in range(16): NSCursor.unhide() except Exception: pass elif _IS_WIN: import ctypes user32 = ctypes.windll.user32 for _ in range(64): if user32.ShowCursor(True) >= 0: break self._cursor_hidden = False except Exception as exc: self._log(f"커서 표시 실패: {exc}") self._cursor_hidden = False def _warp_mouse(self, x: int, y: int) -> None: """Warp cursor; use Quartz on macOS for reliability.""" try: if _IS_MAC: import Quartz # Do NOT call CGAssociate on every warp — it resets relative state # and soft-grab often samples dx≈0. Quartz.CGWarpMouseCursorPosition((float(x), float(y))) if self._mouse_ctl is not None and not _IS_MAC: self._mouse_ctl.position = (x, y) except Exception: try: if self._mouse_ctl is not None: self._mouse_ctl.position = (x, y) except Exception: pass def _read_cursor_pos(self) -> tuple[float, float] | None: """Screen cursor position in global top-left coords.""" try: if self._mouse_ctl is not None: x, y = self._mouse_ctl.position return float(x), float(y) except Exception: pass if _IS_MAC: pos = _mac_mouse_xy() if pos is not None: return pos return None def _start_capture_locked(self) -> None: self._stop_capture_locked() self._suppressing = True self._move_from_tap = False # Grab the display where the cursor currently is — confine to it so # multi-monitor layouts (e.g. a screen below) cannot steal the pointer. try: pos = self._mouse_ctl.position if self._mouse_ctl else (0, 0) disp = _display_at(float(pos[0]), float(pos[1])) except Exception: disp = None if disp is None: w, h = self._local_size() disp = (0.0, 0.0, float(w), float(h)) rx, ry, rw, rh = disp self._grab_rect = (float(rx), float(ry), float(rw), float(rh)) self._grab_center = ( max(1, int(rx + rw / 2)), max(1, int(ry + rh / 2)), ) self._delta_capture = False self._soft_grab = False # Keep mouse↔cursor associated so HID still produces move events. # Hide the local pointer; moves are either swallowed by the event tap # or soft-warped back to centre (never leave a usable local click target). if _IS_MAC: _mac_set_mouse_associated(True) self._warp_mouse(*self._grab_center) self._hide_local_cursor() if _IS_WIN: _win_clip_cursor(self._grab_rect) if self.session is not None: try: self.session.set_km_busy(True) except Exception: pass self._cap_stop.clear() # Keyboard: low-level hook/tap swallows IME events pynput cannot suppress. self._kb_from_hook = False if _IS_MAC: # Keyboard capture needs Input Monitoring (separate from Accessibility). # Without it the tap still delivers mouse events but silently drops keys. km_ok = _mac_input_monitoring_ok() if not km_ok: _mac_request_input_monitoring() self._log( "키보드 전달 불가 — 시스템 설정 → 개인정보 보호 및 보안 → " "입력 모니터링에서 Terminal/Python(이 앱)을 켠 뒤 앱을 재시작하고 " "다시 상대 PC로 넘기세요. (마우스는 손쉬운 사용만으로 동작하지만 " "키 입력은 입력 모니터링 권한이 필요합니다.)" ) ok_move = self._start_mac_input_tap(include_move=True, include_keyboard=km_ok) if ok_move: # Only treat the tap as our keyboard source when Input Monitoring # is granted; otherwise the tap silently drops keys, so leave # _kb_from_hook False to let the pynput keyboard fallback try. self._kb_from_hook = km_ok self._move_from_tap = True self._soft_grab = False _mac_zero_event_suppression() _mac_set_mouse_associated(False) self._hide_local_cursor() if km_ok: self._log("이벤트 탭(이동+클릭+키보드) OK — 상대 PC로 전달") else: self._log( "이벤트 탭(이동+클릭) OK — 키보드는 입력 모니터링 권한 허용 후 " "재시작해야 상대 PC로 전달됩니다" ) else: self._move_from_tap = False self._soft_grab = True self._log( "입력 탭 실패 — 손쉬운 사용에서 Terminal/Python 허용 후 " "다시 가장자리로 넘기세요 (소프트 그랩 폴백)" ) self._start_pynput_mouse_fallback(suppress=False) _mac_set_mouse_associated(True) self._warp_mouse(*self._grab_center) self._hide_local_cursor() elif _IS_WIN: ok_hook = self._start_win_input_hooks() if ok_hook: self._kb_from_hook = True else: self._log("입력 훅 실패 — 클릭/키가 이 PC에도 적용될 수 있습니다") self._start_pynput_mouse_fallback(suppress=False) self._soft_grab = True self._warp_mouse(*self._grab_center) self._hide_local_cursor() else: self._start_pynput_mouse_fallback(suppress=True) self._soft_grab = True if not self._kb_from_hook: try: from pynput.keyboard import Listener as KeyListener self._key_listener = KeyListener( on_press=lambda key: self._cap_key(key, True), on_release=lambda key: self._cap_key(key, False), suppress=True, ) self._key_listener.start() except Exception as exc: self._log(f"키보드 가로채기 실패(손쉬운 사용 권한 확인): {exc}") try: from pynput.keyboard import Listener as KeyListener self._key_listener = KeyListener( on_press=lambda key: self._cap_key(key, True), on_release=lambda key: self._cap_key(key, False), suppress=False, ) self._key_listener.start() self._log("경고: 키 억제 실패 — 입력이 이 PC에도 적용될 수 있습니다") except Exception as exc2: self._log(f"키보드 리스너 실패: {exc2}") need_poll = True if _IS_MAC and self._move_from_tap and not self._soft_grab: need_poll = True elif _IS_MAC and not self._move_from_tap: need_poll = True if need_poll: self._cap_thread = threading.Thread( target=self._capture_loop, name="juc500-input-cap", daemon=True ) self._cap_thread.start() self._log( "상대 PC 제어 중 — 이 PC 커서는 숨기고 움직임은 상대로 전달" ) def _start_pynput_mouse_fallback(self, *, suppress: bool) -> None: try: from pynput.mouse import Listener as MouseListener self._mouse_listener = MouseListener( on_click=lambda x, y, button, pressed: self._cap_click(button, pressed), on_scroll=lambda x, y, dx, dy: self._cap_scroll(dx, dy), suppress=suppress, ) self._mouse_listener.start() if not suppress: self._log("경고: 클릭 억제 실패 — 클릭이 이 PC에도 적용될 수 있습니다") except Exception as exc: self._log(f"마우스 클릭 캡처 실패: {exc}") def _apply_captured_delta(self, dx: int, dy: int) -> bool: """Forward a relative move to the peer; release if virtual cursor hits left edge. Returns False if capture should stop (released / inactive). """ if dx == 0 and dy == 0: return True # Clamp absurd jumps from display switches / warp glitches if abs(dx) > MAX_DELTA_PER_POLL or abs(dy) > MAX_DELTA_PER_POLL: dx = max(-MAX_DELTA_PER_POLL, min(MAX_DELTA_PER_POLL, dx)) dy = max(-MAX_DELTA_PER_POLL, min(MAX_DELTA_PER_POLL, dy)) gdx = int(round(dx * MOVE_GAIN)) gdy = int(round(dy * MOVE_GAIN)) if gdx == 0 and gdy == 0: return True release = False session = None with self._lock: if not self.controlling_remote or not self._capture_armed or self.session is None: return False session = self.session self._vx += gdx self._vy += gdy self._vx = max(0.0, min(float(self._remote_w - 1), self._vx)) self._vy = max(0.0, min(float(self._remote_h - 1), self._vy)) self._move_sent += 1 now = time.monotonic() release = self._should_return_from_remote_left(self._vx, now) move_n = self._move_sent acked = self._remote_acked abs_x, abs_y = int(self._vx), int(self._vy) try: session.send_input_move(abs_x, abs_y) except Exception as exc: self._log(f"이동 전송 실패: {exc}") return True if release: with self._lock: if self.controlling_remote: self._log( f"왼쪽 가장자리 복귀 (가상 x≤{RETURN_EDGE_PX}px, " f"{RETURN_EDGE_DWELL_S:.2f}s 대기)" ) self._release_local_locked() return False if move_n == 1 or move_n % 800 == 0: self._log( f"상대에 마우스 전달 중… ({move_n}회" f"{', 응답 OK' if acked else ', 응답 대기'})" ) return True def _start_mac_input_tap(self, *, include_move: bool, include_keyboard: bool = False) -> bool: """Swallow clicks/scroll/keys (and optionally moves) so local apps stay idle.""" self._mac_move_n = 0 try: import Quartz mask = ( Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseDown) | Quartz.CGEventMaskBit(Quartz.kCGEventLeftMouseUp) | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseDown) | Quartz.CGEventMaskBit(Quartz.kCGEventRightMouseUp) | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) | Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel) ) key_types = () if include_keyboard: key_types = ( Quartz.kCGEventKeyDown, Quartz.kCGEventKeyUp, Quartz.kCGEventFlagsChanged, ) for kt in key_types: mask |= Quartz.CGEventMaskBit(kt) move_types = () if include_move: move_types = ( Quartz.kCGEventMouseMoved, Quartz.kCGEventLeftMouseDragged, Quartz.kCGEventRightMouseDragged, Quartz.kCGEventOtherMouseDragged, ) for mt in move_types: mask |= Quartz.CGEventMaskBit(mt) delta_x_field = getattr(Quartz, "kCGMouseEventDeltaX", 4) delta_y_field = getattr(Quartz, "kCGMouseEventDeltaY", 5) def _callback(proxy, etype, event, refcon): try: with self._lock: active = ( self._suppressing and self.controlling_remote and self._capture_armed and self.session is not None ) if not active: return event if move_types and etype in move_types: dx = int( Quartz.CGEventGetIntegerValueField(event, delta_x_field) ) dy = int( Quartz.CGEventGetIntegerValueField(event, delta_y_field) ) if dx or dy: self._apply_captured_delta(dx, dy) return None # swallow — local cursor frozen by disassociate if etype in ( Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp, ): self._cap_click_id(1, etype == Quartz.kCGEventLeftMouseDown) return None if etype in ( Quartz.kCGEventRightMouseDown, Quartz.kCGEventRightMouseUp, ): self._cap_click_id(2, etype == Quartz.kCGEventRightMouseDown) return None if etype in ( Quartz.kCGEventOtherMouseDown, Quartz.kCGEventOtherMouseUp, ): self._cap_click_id(3, etype == Quartz.kCGEventOtherMouseDown) return None if etype == Quartz.kCGEventScrollWheel: dy = int( Quartz.CGEventGetIntegerValueField( event, Quartz.kCGScrollWheelEventDeltaAxis1 ) ) dx = int( Quartz.CGEventGetIntegerValueField( event, Quartz.kCGScrollWheelEventDeltaAxis2 ) ) if dx or dy: self._cap_scroll(dx, dy) return None if key_types and etype in key_types: pair = _mac_key_from_cgevent(event, etype) if pair is not None: kname, kpressed = pair self._cap_key_by_name(kname, kpressed) return None except Exception: pass return event # Session tap FIRST (matches the working reference). A HID tap sits # ahead of the WindowServer and, combined with swallowing moves, # fights CGAssociateMouseAndMouseCursorPosition(False) so the local # cursor keeps moving. A session tap lets disassociation freeze the # pointer cleanly while we still read per-event HID deltas. tap = Quartz.CGEventTapCreate( Quartz.kCGSessionEventTap, Quartz.kCGHeadInsertEventTap, Quartz.kCGEventTapOptionDefault, mask, _callback, None, ) tap_kind = "session" if not tap: # Fallback: HID tap tap = Quartz.CGEventTapCreate( Quartz.kCGHIDEventTap, Quartz.kCGHeadInsertEventTap, Quartz.kCGEventTapOptionDefault, mask, _callback, None, ) tap_kind = "hid" if not tap: self._mac_tap_kind = None self._log("macOS 입력 가로채기 실패 — 손쉬운 사용에서 허용하세요") return False self._mac_tap_kind = tap_kind self._mac_tap_callback = _callback # prevent GC of callback source = Quartz.CFMachPortCreateRunLoopSource(None, tap, 0) self._mac_tap = tap self._mac_tap_source = source def _run() -> None: Quartz.CFRunLoopAddSource( Quartz.CFRunLoopGetCurrent(), source, Quartz.kCFRunLoopCommonModes, ) Quartz.CGEventTapEnable(tap, True) # Freeze + hide the local cursor from THIS (tap) thread. Calling # CGAssociateMouseAndMouseCursorPosition(False) from a background # thread (e.g. the ACK waiter) frequently does NOT stick, so the # pointer keeps moving locally even though moves are forwarded. # macOS also re-associates ~0.25s after certain ops, so re-assert # on every run-loop tick (well under that window). if include_move: try: _mac_zero_event_suppression() _mac_set_mouse_associated(False) _mac_set_mouse_associated(False) Quartz.CGDisplayHideCursor(Quartz.CGMainDisplayID()) except Exception: pass while not self._cap_stop.is_set(): Quartz.CFRunLoopRunInMode(Quartz.kCFRunLoopDefaultMode, 0.05, False) if include_move: try: _mac_set_mouse_associated(False) except Exception: pass self._mac_tap_thread = threading.Thread( target=_run, name="juc500-mac-tap", daemon=True ) self._mac_tap_thread.start() return True except Exception as exc: self._log(f"macOS 입력 탭 오류: {exc}") return False def _start_win_input_hooks(self) -> bool: """Low-level mouse+keyboard hooks: swallow clicks/scroll/keys, allow moves.""" try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 WH_MOUSE_LL = 14 WH_KEYBOARD_LL = 13 WM_LBUTTONDOWN, WM_LBUTTONUP = 0x0201, 0x0202 WM_RBUTTONDOWN, WM_RBUTTONUP = 0x0204, 0x0205 WM_MBUTTONDOWN, WM_MBUTTONUP = 0x0207, 0x0208 WM_MOUSEWHEEL, WM_MOUSEHWHEEL = 0x020A, 0x020E WM_KEYDOWN, WM_KEYUP = 0x0100, 0x0101 WM_SYSKEYDOWN, WM_SYSKEYUP = 0x0104, 0x0105 HC_ACTION = 0 LLKHF_INJECTED = 0x10 class MSLLHOOKSTRUCT(ctypes.Structure): _fields_ = [ ("pt", wintypes.POINT), ("mouseData", wintypes.DWORD), ("flags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), ] class KBDLLHOOKSTRUCT(ctypes.Structure): _fields_ = [ ("vkCode", wintypes.DWORD), ("scanCode", wintypes.DWORD), ("flags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), ] LongPtr = getattr(wintypes, "LPARAM", ctypes.c_long) hooks: dict[str, Any] = {"mouse": None, "kbd": None} def _low_level_mouse(nCode, wParam, lParam): try: if nCode == HC_ACTION: with self._lock: active = ( self._suppressing and self.controlling_remote and self._capture_armed and self.session is not None ) if active: wp = int(wParam) if wp in (WM_LBUTTONDOWN, WM_LBUTTONUP): self._cap_click_id(1, wp == WM_LBUTTONDOWN) return 1 if wp in (WM_RBUTTONDOWN, WM_RBUTTONUP): self._cap_click_id(2, wp == WM_RBUTTONDOWN) return 1 if wp in (WM_MBUTTONDOWN, WM_MBUTTONUP): self._cap_click_id(3, wp == WM_MBUTTONDOWN) return 1 if wp in (WM_MOUSEWHEEL, WM_MOUSEHWHEEL): info = ctypes.cast( lParam, ctypes.POINTER(MSLLHOOKSTRUCT) ).contents delta = ctypes.c_short(info.mouseData >> 16).value if wp == WM_MOUSEWHEEL: self._cap_scroll(0, 1 if delta > 0 else -1) else: self._cap_scroll(1 if delta > 0 else -1, 0) return 1 except Exception: pass return user32.CallNextHookEx(hooks["mouse"], nCode, wParam, lParam) def _low_level_kbd(nCode, wParam, lParam): try: if nCode == HC_ACTION: with self._lock: active = ( self._suppressing and self.controlling_remote and self._capture_armed and self.session is not None ) if active: wp = int(wParam) if wp in (WM_KEYDOWN, WM_SYSKEYDOWN): pressed = True elif wp in (WM_KEYUP, WM_SYSKEYUP): pressed = False else: return user32.CallNextHookEx( hooks["kbd"], nCode, wParam, lParam ) info = ctypes.cast( lParam, ctypes.POINTER(KBDLLHOOKSTRUCT) ).contents if info.flags & LLKHF_INJECTED: return user32.CallNextHookEx( hooks["kbd"], nCode, wParam, lParam ) self._cap_key_by_name( _win_vk_to_name(int(info.vkCode)), pressed ) return 1 except Exception: pass return user32.CallNextHookEx(hooks["kbd"], nCode, wParam, lParam) CMPFUNC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int, wintypes.WPARAM, LongPtr) self._win_mouse_proc = CMPFUNC(_low_level_mouse) self._win_kbd_proc = CMPFUNC(_low_level_kbd) ready: dict[str, bool] = {"ok": False} def _run() -> None: hooks["mouse"] = user32.SetWindowsHookExW( WH_MOUSE_LL, self._win_mouse_proc, None, 0 ) hooks["kbd"] = user32.SetWindowsHookExW( WH_KEYBOARD_LL, self._win_kbd_proc, None, 0 ) self._win_mouse_hook = hooks["mouse"] ready["ok"] = bool(hooks["mouse"] and hooks["kbd"]) if not ready["ok"]: if hooks["mouse"]: user32.UnhookWindowsHookEx(hooks["mouse"]) if hooks["kbd"]: user32.UnhookWindowsHookEx(hooks["kbd"]) hooks["mouse"] = None hooks["kbd"] = None self._win_mouse_hook = None return msg = wintypes.MSG() while not self._cap_stop.is_set(): while user32.PeekMessageW(ctypes.byref(msg), 0, 0, 0, 1): user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) time.sleep(0.01) for key in ("mouse", "kbd"): if hooks[key]: user32.UnhookWindowsHookEx(hooks[key]) hooks[key] = None self._win_mouse_hook = None self._win_hook_thread = threading.Thread( target=_run, name="juc500-win-mhook", daemon=True ) self._win_hook_thread.start() time.sleep(0.15) return ready["ok"] except Exception as exc: self._log(f"Windows 입력 훅 오류: {exc}") return False def _start_win_button_hook(self) -> bool: """Backward-compatible alias.""" return self._start_win_input_hooks() def _send_click_to_peer( self, session: Any, button: int, pressed: bool, x: int, y: int ) -> None: """Send click off the event-tap thread (tap callback must not block USB).""" try: session.send_input_button(button, pressed, x, y) with self._lock: self._click_sent += 1 n = self._click_sent if n == 1 or n % 40 == 0: act = "누름" if pressed else "뗌" self._log( f"클릭 전송 ({act}, btn={button}, {x},{y}) — {n}회" ) except Exception as exc: self._log(f"클릭 전송 실패: {exc}") def _cap_click_id(self, button: int, pressed: bool) -> None: with self._lock: active = ( self.controlling_remote and self._capture_armed and self.session is not None ) session = self.session # Press pins the button at the current spot; release must use the # CURRENT (possibly dragged-to) position so title-bar/selection # drags land where the cursor actually ended up. if pressed: self._click_anchor = (int(self._vx), int(self._vy)) else: self._click_anchor = None vx, vy = int(self._vx), int(self._vy) if not active or session is None: return threading.Thread( target=self._send_click_to_peer, args=(session, button, pressed, vx, vy), name="juc500-click-tx", daemon=True, ).start() def _stop_capture_locked(self) -> None: self._suppressing = False self._warping = False self._cap_stop.set() if _IS_WIN: _win_clip_cursor(None) self._grab_rect = None self._move_from_tap = False # Restore local input ASAP (before joining worker threads). # pynput suppress=True blocks the OS until the listener is stopped. for lst in (self._key_listener, self._mouse_listener): if lst is not None: try: lst.stop() except Exception: pass key_lst = self._key_listener mouse_lst = self._mouse_listener self._key_listener = None self._mouse_listener = None if _IS_MAC: _mac_set_mouse_associated(True) try: import Quartz if self._mac_tap is not None: Quartz.CGEventTapEnable(self._mac_tap, False) if self._mac_tap_source is not None: try: Quartz.CFRunLoopSourceInvalidate(self._mac_tap_source) except Exception: pass except Exception: pass # Join workers after unsuppress t = self._cap_thread self._cap_thread = None if t and t.is_alive() and t is not threading.current_thread(): t.join(timeout=1.0) if _IS_MAC: mt = self._mac_tap_thread self._mac_tap_thread = None if mt and mt.is_alive() and mt is not threading.current_thread(): mt.join(timeout=0.5) self._mac_tap = None self._mac_tap_source = None self._mac_tap_callback = None if _IS_WIN: ht = self._win_hook_thread self._win_hook_thread = None if ht and ht.is_alive() and ht is not threading.current_thread(): ht.join(timeout=0.8) self._win_mouse_hook = None self._win_mouse_proc = None self._win_kbd_proc = None self._kb_from_hook = False for lst in (key_lst, mouse_lst): if lst is not None: try: lst.join(timeout=0.5) except Exception: pass self._delta_capture = False if self.session is not None: try: self.session.flush_input_moves() except Exception: pass try: if not self.being_controlled: self.session.set_km_busy(False) except Exception: pass self._show_local_cursor() def _capture_loop(self) -> None: """Soft-grab: sample position vs centre, forward delta, warp back. For the event-tap path (macOS) moves arrive via the tap callback; this loop only re-asserts the frozen/hidden cursor periodically. """ err_log_at = 0.0 last_reassert = 0.0 while not self._cap_stop.is_set(): try: with self._lock: active = ( self.controlling_remote and self._capture_armed and self.session is not None ) cx, cy = self._grab_center grab = self._grab_rect soft = bool(self._soft_grab) from_tap = bool(self._move_from_tap) if not active: break # Event-tap move path: cursor is disassociated + hidden. # Periodically re-assert so a focus change can't reveal it. if from_tap and not soft: now = time.monotonic() if now - last_reassert > 0.5: last_reassert = now if _IS_MAC: try: import Quartz _mac_set_mouse_associated(False) Quartz.CGDisplayHideCursor(Quartz.kCGNullDirectDisplay) except Exception: pass self._cap_stop.wait(0.05) continue pos = self._read_cursor_pos() if pos is None: self._cap_stop.wait(0.005) continue fx, fy = pos if grab is not None and not _point_in_rect(fx, fy, grab, margin=0.0): try: self._warp_mouse(cx, cy) except Exception: pass self._cap_stop.wait(0.001) continue raw_dx = int(round(fx - cx)) raw_dy = int(round(fy - cy)) if raw_dx or raw_dy: if abs(raw_dx) > MAX_DELTA_PER_POLL or abs(raw_dy) > MAX_DELTA_PER_POLL: raw_dx = max(-MAX_DELTA_PER_POLL, min(MAX_DELTA_PER_POLL, raw_dx)) raw_dy = max(-MAX_DELTA_PER_POLL, min(MAX_DELTA_PER_POLL, raw_dy)) still = self._apply_captured_delta(raw_dx, raw_dy) if not still: break # Re-check before warping — release may have restored local cursor if self._cap_stop.is_set() or not self.controlling_remote: break try: self._warp_mouse(cx, cy) except Exception: pass except Exception as exc: now = time.monotonic() if now >= err_log_at: err_log_at = now + 2.0 self._log(f"마우스 캡처 오류: {exc}") self._cap_stop.wait(0.05) self._cap_stop.wait(0.002) def _cap_move(self, x: int, y: int) -> bool: # Retained for compatibility; motion is handled by _capture_loop. return False def _cap_click(self, button: Any, pressed: bool) -> bool: with self._lock: active = ( self.controlling_remote and self._capture_armed and self.session is not None ) session = self.session if pressed: self._click_anchor = (int(self._vx), int(self._vy)) else: self._click_anchor = None vx, vy = int(self._vx), int(self._vy) if not active or session is None: return True bid = 1 name = getattr(button, "name", "") or str(button) if "right" in name: bid = 2 elif "middle" in name: bid = 3 threading.Thread( target=self._send_click_to_peer, args=(session, bid, pressed, vx, vy), name="juc500-click-tx", daemon=True, ).start() return False def _cap_scroll(self, dx: int, dy: int) -> bool: with self._lock: active = ( self.controlling_remote and self._capture_armed and self.session is not None ) session = self.session if not active or session is None: return True try: session.send_input_wheel(int(dx), int(dy)) except Exception as exc: self._log(f"스크롤 전송 실패: {exc}") return False def _cap_key_by_name(self, key_name: str, pressed: bool) -> None: with self._lock: active = ( self.controlling_remote and self._capture_armed and self.session is not None ) session = self.session if not active: return name = key_name.lower() if pressed and name in ("esc", "escape"): cb = self.on_escape_quit self._log("Esc — 입력공유 끄고 프로그램 종료") if cb is not None: threading.Thread( target=cb, name="juc500-esc-quit", daemon=True ).start() return if name in ("cmd", "cmd_l", "cmd_r", "command", "super", "windows", "win"): self._mod_cmd = bool(pressed) elif name in ("ctrl", "ctrl_l", "ctrl_r", "control"): self._mod_ctrl = bool(pressed) elif name in ("shift", "shift_l", "shift_r"): self._mod_shift = bool(pressed) elif name in ("alt", "alt_l", "alt_r", "option"): self._mod_alt = bool(pressed) toggle_combo = ( pressed and name in ("r", "vk:82", "vk:15") and self._mod_ctrl and not self._mod_cmd and not self._mod_alt ) paste_combo = False if pressed and name in ("v", "vk:86"): paste_combo = ( (self._mod_cmd or self._mod_ctrl) and not self._mod_alt and not self._mod_shift ) send_name = key_name if name == "caps_lock": # A single Caps Lock tap on this Mac emits a quick ON→OFF pair of # flags-changed events. USB wait for the first pulse is ~400ms, so # the second event arrives after 0.3s — use a 1.2s coalesce window # or both toggles cancel out on Windows (net zero). now = time.monotonic() if now - self._last_caps_at < 1.2: return self._last_caps_at = now try: self._send_key_pulse(session, "caps_lock") self._log("Mac Caps Lock → 상대 Windows 대소문자 전환") except Exception as exc: self._log(f"Caps Lock 전달 실패: {exc}") return if name in ("cmd_r", "alt_r"): # Right Command / Right Option are dedicated remote 한/영 keys. # Only key-down toggles; debounce rapid repeats from one physical tap. if pressed: now = time.monotonic() last = getattr(self, "_last_hangul_at", 0.0) if now - last < 0.5: return self._last_hangul_at = now try: self._send_key_pulse(session, "hangul") self._log( f"Mac {name} → 상대 Windows 한/영 전환" ) except Exception as exc: self._log(f"한/영 전환 전달 실패: {exc}") return if toggle_combo: # Consume Ctrl+R locally. _release_local_locked sends modifier-up to # the peer so Ctrl cannot remain stuck after the handoff. self._trigger_control_toggle() return if paste_combo: try: self._push_clipboard_text_to_peer() except Exception: pass # Surface unmapped / IME keys once each so a mis-detected 한영/한자 # key can be identified from the log (raw mac keycode arrives as vk:NN). if pressed and (send_name.startswith("vk:") or send_name in ("hangul", "hanja")): seen = getattr(self, "_logged_special_keys", None) if seen is None: seen = set() self._logged_special_keys = seen if send_name not in seen: seen.add(send_name) self._log(f"특수/미매핑 키 감지 → 상대 전송: '{send_name}'") try: session.send_input_key(send_name, pressed) self._key_sent_count += 1 if self._key_sent_count == 1 or self._key_sent_count % 50 == 0: self._log( f"키 전송 → 상대 PC ('{send_name}' {'누름' if pressed else '뗌'}, " f"{self._key_sent_count}회)" ) except Exception as exc: self._log(f"키 전송 실패: {exc}") def _send_key_pulse(self, session: Any, name: str) -> None: """Send one key-down for a toggle key (Caps/Hangul). Windows handlers ignore the key-up frame and perform a full local down+up (or IME API toggle) on key-down — so a second USB packet is wasted latency that also breaks Caps Lock coalescing. """ session.send_input_key(name, True) self._key_sent_count += 1 def _cap_key(self, key: Any, pressed: bool) -> bool: with self._lock: active = ( self.controlling_remote and self._capture_armed and self.session is not None ) if not active: return True self._cap_key_by_name(_key_name(key), pressed) return False def _push_clipboard_text_to_peer(self) -> None: """Copy local plain-text clipboard onto the peer before a remote paste.""" if self.session is None: return try: from .clipboard_files import get_clipboard_text text = get_clipboard_text() except Exception: text = "" if not text: return try: self.session.send_input_clipboard(text) except Exception as exc: self._log(f"클립보드 동기화 실패: {exc}") def _on_peer_clipboard(self, text: str) -> None: """Peer pushed plain text so an upcoming Ctrl/Cmd+V pastes on this PC.""" try: from .clipboard_files import set_clipboard_text ok = set_clipboard_text(text or "") if ok: self._log(f"클립보드 수신 ({len(text or '')}자) — 이 PC 붙여넣기 준비") else: self._log("클립보드 수신 적용 실패") except Exception as exc: self._log(f"클립보드 적용 실패: {exc}") # --- peer inbound --- def _on_peer_ctrl(self, target: int, width: int, height: int) -> None: if target == INPUT_CTRL_SHARE_ON: self.set_enabled(True, from_peer=True) return if target == INPUT_CTRL_SHARE_OFF: self.set_enabled(False, from_peer=True) return if target == INPUT_CTRL_PEER_CLICK: self._log( f"상대 클릭 주입 확인 — btn={width}, x={height}" ) return if target == INPUT_CTRL_LOCAL: with self._lock: cr = self.controlling_remote bc = self.being_controlled if cr: self._release_local_locked() elif bc: with self._lock: self._stop_being_controlled_locked() self._status = "이 PC 제어" if self.enabled else "꺼짐" self._log("상대가 제어권을 가져감 — 이 PC 로컬 입력 복구") _force_release_os_input() else: _force_release_os_input() return with self._lock: if target == INPUT_CTRL_REMOTE_ACK: if self.controlling_remote and not self._remote_acked: self._apply_remote_ack_locked(width, height) return if target == INPUT_CTRL_REMOTE_NACK: if self.controlling_remote and not self._capture_armed: self._log("상대가 제어 요청을 거부했습니다 (권한 또는 연결 상태 확인)") self._release_local_locked() return if target == INPUT_CTRL_REMOTE: # Never treat REMOTE as ACK — that caused dual-controller deadlocks. if self.controlling_remote and not self._remote_acked: self._log("동시 전환 — 내 요청을 유지하고 상대 요청을 거절") if self.session is not None: try: self.session.send_input_ctrl( INPUT_CTRL_REMOTE_NACK, *self._local_size() ) except Exception: pass return if self.controlling_remote and self._remote_acked: self._ack_gen += 1 self.controlling_remote = False self._suppressing = False self._remote_acked = False self._capture_armed = False self._stop_capture_locked() self._show_local_cursor() _force_release_os_input() if not self.enabled: self._log("입력공유 꺼짐 — 상대 제어 요청 거부 (양쪽 모두 켜야 함)") if self.session is not None: try: self.session.send_input_ctrl( INPUT_CTRL_REMOTE_NACK, *self._local_size() ) except Exception: pass return ok, err = self._ensure_controllers() if not ok: self._log(f"상대 제어 요청 거부: {err}") if self.session is not None: try: self.session.send_input_ctrl( INPUT_CTRL_REMOTE_NACK, *self._local_size() ) except Exception: pass return self._enter_being_controlled_locked(width, height) return if self.controlling_remote: self._release_local_locked() if self.being_controlled: self._stop_being_controlled_locked() self._status = "이 PC 제어" if self.enabled else "꺼짐" _force_release_os_input() def _enter_being_controlled_locked(self, peer_w: int, peer_h: int) -> None: if self.controlling_remote: self._ack_gen += 1 self._stop_capture_locked() self.controlling_remote = False self._remote_acked = False self._capture_armed = False self.being_controlled = True self._injecting = True self._mac_click_state = 0 self._status = "상대가 조종 중" self._arm_cooldown() self._inject_release_until = time.monotonic() + 0.25 self._peer_modifiers.clear() ok, err = self._ensure_controllers() if not ok: self._log(f"주입 컨트롤러 준비 실패: {err}") if self.session is not None: try: self.session.set_km_busy(True) except Exception: pass lw, lh = self._local_size() # Peer entered from the left (their right edge → our left) self._vx = float(REMOTE_ENTER_INSET) self._vy = float(lh) / 2.0 self._log("상대가 이 PC를 제어합니다 — 마우스를 왼쪽 끝으로 보내면 상대가 복귀합니다") # ACK with our screen size so the controller can clamp + start capture. if _IS_MAC: try: _mac_set_mouse_associated(True) _mac_show_cursor() if not _mac_request_accessibility(): self._log( "경고: Mac 손쉬운 사용 미허용 — 커서 이동만 되고 클릭은 안 됩니다. " "시스템 설정에서 이 앱(Python/Terminal)을 허용하세요." ) except Exception: pass # New controllers also accept legacy REMOTE as ack when not yet acked. if self.session: try: self.session.send_input_ctrl(INPUT_CTRL_REMOTE_ACK, lw, lh) except Exception: pass try: self._inject_mouse_pos(int(self._vx), int(self._vy)) self._log( f"상대 커서 시작 위치: ({int(self._vx)}, {int(self._vy)}) " f"화면 {lw}x{lh}" ) except Exception as exc: self._log(f"상대 커서 시작 주입 실패: {exc}") def _stop_being_controlled_locked(self) -> None: was = self.being_controlled self.being_controlled = False self._injecting = False if was: self._clear_injected_input_locked() self._arm_cooldown() if self.session is not None and not self.controlling_remote: try: self.session.set_km_busy(False) except Exception: pass if self.enabled and not self.controlling_remote: self._status = "이 PC 제어" if was: self._log("이 PC 로컬 입력 복구 (수정키·버튼·커서 해제)") def _inject_mouse_pos(self, x: int, y: int) -> None: # Virtual desktop coords (origin top-left, y down) → OS screen coords if _IS_MAC: cg_x, cg_y = _mac_virtual_to_cg( float(x), float(y), self._desk_ox, self._desk_oy, self._local_h ) # If a button is held, emit a Dragged event so drags register. held = 0 for b in (1, 2, 3): if b in self._injected_buttons_down: held = b break if _mac_inject_mouse_pos(int(round(cg_x)), int(round(cg_y)), held): return ax, ay = int(round(cg_x)), int(round(cg_y)) else: ax = int(round(self._desk_ox + float(x))) ay = int(round(self._desk_oy + float(y))) if _IS_WIN: try: import ctypes ctypes.windll.user32.SetCursorPos(ax, ay) return except Exception: pass if self._mouse_ctl is not None: self._mouse_ctl.position = (ax, ay) def _inject_mouse_move_rel(self, dx: int, dy: int) -> tuple[float, float]: """Apply relative move; return current cursor position.""" if _IS_WIN: try: import ctypes from ctypes import wintypes # Prefer SendInput (lower latency / less stutter than mouse_event) ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong class MOUSEINPUT(ctypes.Structure): _fields_ = [ ("dx", wintypes.LONG), ("dy", wintypes.LONG), ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ULONG_PTR), ] class INPUT(ctypes.Structure): class _I(ctypes.Union): _fields_ = [("mi", MOUSEINPUT)] _anonymous_ = ("i",) _fields_ = [("type", wintypes.DWORD), ("i", _I)] inp = INPUT() inp.type = 0 # INPUT_MOUSE inp.mi = MOUSEINPUT(int(dx), int(dy), 0, 0x0001, 0, 0) ctypes.windll.user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT)) class POINT(ctypes.Structure): _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)] pt = POINT() ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)) return float(pt.x), float(pt.y) except Exception: try: import ctypes ctypes.windll.user32.mouse_event(0x0001, int(dx), int(dy), 0, 0) class POINT(ctypes.Structure): _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] pt = POINT() ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)) return float(pt.x), float(pt.y) except Exception: pass if self._mouse_ctl is not None: try: self._mouse_ctl.move(int(dx), int(dy)) pos = self._mouse_ctl.position return float(pos[0]), float(pos[1]) except Exception: pass return self._vx + dx, self._vy + dy def _inject_mouse_button( self, button: int, pressed: bool, vx: int = -1, vy: int = -1 ) -> None: if pressed: self._injected_buttons_down.add(int(button)) else: self._injected_buttons_down.discard(int(button)) if vx >= 0 and vy >= 0: with self._lock: w, h = self._local_w, self._local_h self._vx = float(max(0, min(max(1, w - 1), int(vx)))) self._vy = float(max(0, min(max(1, h - 1), int(vy)))) if _IS_WIN: try: import ctypes # MOUSEEVENTF_LEFTDOWN=2 LEFTUP=4 RIGHTDOWN=8 RIGHTUP=16 MIDDLEDOWN=32 MIDDLEUP=64 mapping = { (1, True): 0x0002, (1, False): 0x0004, (2, True): 0x0008, (2, False): 0x0010, (3, True): 0x0020, (3, False): 0x0040, } flag = mapping.get((button, pressed)) if flag is not None: ctypes.windll.user32.mouse_event(flag, 0, 0, 0, 0) return except Exception: pass elif _IS_MAC: self._local_size() cg_x, cg_y = _mac_virtual_to_cg( float(self._vx), float(self._vy), self._desk_ox, self._desk_oy, self._local_h, ) ax, ay = int(round(cg_x)), int(round(cg_y)) click_state = 1 if int(button) == 1: if pressed: now = time.monotonic() if now - getattr(self, "_mac_last_down_at", 0.0) > 0.35: self._mac_click_state = 0 self._mac_last_down_at = now self._mac_click_state = max(1, int(self._mac_click_state) + 1) click_state = max(1, int(self._mac_click_state)) _mac_inject_mouse_pos(ax, ay) _mac_inject_mouse_button( ax, ay, button, pressed, click_state=click_state ) return from pynput.mouse import Button btn = Button.left if button == 2: btn = Button.right elif button == 3: btn = Button.middle if self._mouse_ctl is None: return if pressed: self._mouse_ctl.press(btn) else: self._mouse_ctl.release(btn) def _on_peer_move(self, x: int, y: int) -> None: """Apply peer's absolute virtual cursor position on this screen.""" release = False session = None w = h = 0 ax = ay = 0 with self._lock: if not self.being_controlled: return now = time.monotonic() session = self.session w, h = self._local_w, self._local_h ax = max(0, min(max(1, w - 1), int(x))) ay = max(0, min(max(1, h - 1), int(y))) self._vx = float(ax) self._vy = float(ay) release = self._should_peer_return_left(float(ax), now) try: # Absolute inject is more reliable than stacking relative SendInput self._inject_mouse_pos(ax, ay) except Exception as exc: self._log(f"마우스 주입 실패: {exc}") return if release: with self._lock: if self.being_controlled: self._stop_being_controlled_locked() if session: try: session.send_input_ctrl(INPUT_CTRL_LOCAL, w, h) except Exception: pass self._log("왼쪽 가장자리 — 상대에게 제어권 반환 요청") _force_release_os_input() def _peer_perform_click(self, button: int, x: int, y: int) -> None: """Peer-side: warp to position and deliver one full click (down+up).""" if x >= 0 and y >= 0: with self._lock: w, h = self._local_w, self._local_h self._vx = float(max(0, min(max(1, w - 1), int(x)))) self._vy = float(max(0, min(max(1, h - 1), int(y)))) self._local_size() if _IS_MAC: cg_x, cg_y = _mac_virtual_to_cg( float(self._vx), float(self._vy), self._desk_ox, self._desk_oy, self._local_h, ) ax, ay = int(round(cg_x)), int(round(cg_y)) if not _mac_accessibility_ok(): _mac_request_accessibility() if not _mac_click_at(ax, ay, int(button)): raise RuntimeError( "Mac 클릭 주입 실패 — 시스템 설정 → 손쉬운 사용에서 " "Python/Terminal 허용 후 재시도" ) else: ax = int(round(self._desk_ox + float(self._vx))) ay = int(round(self._desk_oy + float(self._vy))) self._inject_mouse_pos(int(self._vx), int(self._vy)) ok, err = self._ensure_controllers() if not ok: raise RuntimeError(err or "입력 컨트롤러 없음") from pynput.mouse import Button pbtn = {1: Button.left, 2: Button.right, 3: Button.middle}.get( int(button), Button.left ) if self._mouse_ctl is None: raise RuntimeError("마우스 컨트롤러 없음") try: self._mouse_ctl.position = (ax, ay) with self._mouse_ctl: self._mouse_ctl.click(pbtn, 1) except Exception: if _IS_WIN: self._inject_mouse_button(button, True, x, y) self._inject_mouse_button(button, False, x, y) else: raise session = self.session if session is not None: try: session.send_input_ctrl( INPUT_CTRL_PEER_CLICK, int(button), max(0, min(65535, int(x if x >= 0 else self._vx))), ) except Exception: pass def _on_peer_button( self, button: int, pressed: bool, x: int = -1, y: int = -1 ) -> None: # Hold the button between press and release so title-bar / selection # drags work: press = button-down (held), release = button-up. A quick # down+up at the same spot is still a normal click. with self._lock: if not self.being_controlled: return self._peer_click_recv += 1 n = self._peer_click_recv if not pressed and int(button) not in self._injected_buttons_down: # Release with no matching press we injected — nothing to do, but # still push a safety up event so a stuck button can't linger. try: self._inject_mouse_button(button, False, x, y) except Exception: pass return try: # Land the cursor at the event location first, then press/release. if x >= 0 and y >= 0: with self._lock: w, h = self._local_w, self._local_h self._vx = float(max(0, min(max(1, w - 1), int(x)))) self._vy = float(max(0, min(max(1, h - 1), int(y)))) self._inject_mouse_pos(int(self._vx), int(self._vy)) if not _IS_MAC: self._ensure_controllers() self._inject_mouse_button(button, pressed, x, y) except Exception as exc: self._log(f"클릭 주입 실패: {exc}") return if pressed: session = self.session if session is not None: try: session.send_input_ctrl( INPUT_CTRL_PEER_CLICK, int(button), max(0, min(65535, int(x if x >= 0 else self._vx))), ) except Exception: pass if n <= 3 or n % 20 == 0: act = "누름" if pressed else "뗌" self._log(f"클릭 주입 ({act}, btn={button}, {x},{y}) — {n}회") def _on_peer_wheel(self, dx: int, dy: int) -> None: with self._lock: if not self.being_controlled: return if _IS_WIN: # Inject natively — clicks use mouse_event, so _mouse_ctl (pynput) # is often never created on the peer, which silently dropped scroll. try: import ctypes MOUSEEVENTF_WHEEL = 0x0800 MOUSEEVENTF_HWHEEL = 0x01000 WHEEL_DELTA = 120 user32 = ctypes.windll.user32 if dy: user32.mouse_event( MOUSEEVENTF_WHEEL, 0, 0, ctypes.c_int(int(dy) * WHEEL_DELTA), 0 ) if dx: user32.mouse_event( MOUSEEVENTF_HWHEEL, 0, 0, ctypes.c_int(int(dx) * WHEEL_DELTA), 0 ) return except Exception as exc: self._log(f"스크롤 주입 실패(Win): {exc}") return # macOS / other: use the pynput controller (create it if needed). self._ensure_controllers() if self._mouse_ctl is not None: try: self._mouse_ctl.scroll(int(dx), int(dy)) except Exception as exc: self._log(f"스크롤 주입 실패: {exc}") def _on_peer_key(self, name: str, pressed: bool) -> None: inject_name = name active_mods: set[str] key_ctl = None with self._lock: if not self.being_controlled: return self._key_recv_count += 1 recv_n = self._key_recv_count if _IS_WIN and name.lower() in ( "cmd", "cmd_l", "cmd_r", "command", "super", "windows", "win", ): inject_name = "ctrl" low = inject_name.lower() if low in _MAC_MODIFIER_NAMES: if pressed: self._peer_modifiers.add(low) else: self._peer_modifiers.discard(low) active_mods = set(self._peer_modifiers) key_ctl = self._key_ctl if recv_n == 1 or recv_n % 50 == 0: self._log( f"상대 키 수신 → 이 PC 주입 ('{inject_name}' " f"{'누름' if pressed else '뗌'}, {recv_n}회)" ) if _IS_MAC: if _mac_inject_key(inject_name, pressed, active_mods): with self._lock: if pressed: self._injected_keys_down.add(inject_name) else: self._injected_keys_down.discard(inject_name) return self._log(f"Mac 키 주입 실패 — '{inject_name}' (권한/매핑 확인)") if _IS_WIN: low_inj = inject_name.lower() if pressed and low_inj in ("caps_lock", "hangul", "hanja"): self._log(f"Windows 토글 주입 시도 — '{inject_name}'") if _win_inject_key(inject_name, pressed, active_mods): with self._lock: if pressed: self._injected_keys_down.add(inject_name) else: self._injected_keys_down.discard(inject_name) if pressed and low_inj in ("caps_lock", "hangul", "hanja"): self._log(f"Windows 토글 주입 완료 — '{inject_name}'") return self._log(f"Windows 키 주입 실패 — '{inject_name}' (VK 매핑 없음)") if key_ctl is None: return key = _key_from_name(inject_name) if key is None: return try: if pressed: with self._lock: self._injected_keys_down.add(inject_name) key_ctl.press(key) else: with self._lock: self._injected_keys_down.discard(inject_name) key_ctl.release(key) except Exception as exc: self._log(f"키 주입 실패: {exc}") # Process-wide singleton for GUI SHARE = InputShare()