Files
juc500/dist-offline/juc500-offline-update-20260720-174241/juc500_xfer/inputshare.py
T
2026-07-21 21:04:25 +09:00

3151 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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-16-kbd-ime1"
# 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
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) -> 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.
"""
try:
import Quartz
_mac_set_mouse_associated(True)
pt = (float(ax), float(ay))
src = _mac_event_source()
Quartz.CGWarpMouseCursorPosition(pt)
_mac_show_cursor()
ev = Quartz.CGEventCreateMouseEvent(
src,
Quartz.kCGEventMouseMoved,
pt,
Quartz.kCGMouseButtonLeft,
)
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",
55: "cmd",
56: "shift",
60: "shift",
58: "alt",
61: "alt",
59: "ctrl",
62: "ctrl",
57: "caps_lock",
}
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
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
kc = int(
Quartz.CGEventGetIntegerValueField(event, Quartz.kCGKeyboardEventKeycode)
)
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",
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_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 KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ULONG_PTR),
]
class INPUT(ctypes.Structure):
class _I(ctypes.Union):
_fields_ = [("ki", KEYBDINPUT)]
_anonymous_ = ("i",)
_fields_ = [("type", wintypes.DWORD), ("i", _I)]
def _send(inp: INPUT) -> None:
user32.SendInput(1, ctypes.byref(inp), ctypes.sizeof(INPUT))
if len(name) == 1 and name.isprintable():
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:
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._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
# Modifier tracking for paste intercept
self._mod_cmd = False
self._mod_ctrl = False
self._mod_shift = False
self._mod_alt = False
# 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,
"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"]
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()
if from_peer:
self._log("상대와 입력공유 동기화 — 켜짐")
else:
self._log(
"입력공유 켜짐 — 모니터 오른쪽 끝(옆 모니터 없을 때) → 상대 PC / "
"상대 왼쪽 끝 → 이 PC / 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_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_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 _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:
ok_move = self._start_mac_input_tap(include_move=True, include_keyboard=True)
if ok_move:
self._kb_from_hook = True
self._move_from_tap = True
self._soft_grab = False
_mac_zero_event_suppression()
_mac_set_mouse_associated(False)
self._hide_local_cursor()
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
if pressed:
self._click_anchor = (int(self._vx), int(self._vy))
vx, vy = self._click_anchor
else:
anchor = self._click_anchor
if anchor is not None:
vx, vy = anchor
else:
vx, vy = int(self._vx), int(self._vy)
self._click_anchor = None
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))
vx, vy = self._click_anchor
else:
anchor = self._click_anchor
if anchor is not None:
vx, vy = anchor
else:
vx, vy = int(self._vx), int(self._vy)
self._click_anchor = None
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)
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 paste_combo:
try:
self._push_clipboard_text_to_peer()
except Exception:
pass
try:
session.send_input_key(send_name, pressed)
except Exception as exc:
self._log(f"키 전송 실패: {exc}")
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 _mac_inject_mouse_pos(int(round(cg_x)), int(round(cg_y))):
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:
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:
with self._lock:
if not self.being_controlled:
return
self._peer_click_recv += 1
n = self._peer_click_recv
if not pressed:
if int(button) in self._injected_buttons_down:
try:
self._inject_mouse_button(button, False, x, y)
except Exception:
pass
return
try:
self._peer_perform_click(button, x, y)
except Exception as exc:
self._log(f"클릭 주입 실패: {exc}")
return
if n <= 3 or n % 20 == 0:
self._log(f"클릭 주입 완료 (btn={button}, {x},{y}) — {n}회")
def _on_peer_wheel(self, dx: int, dy: int) -> None:
with self._lock:
if not self.being_controlled or self._mouse_ctl is None:
return
try:
self._mouse_ctl.scroll(dx, dy)
except Exception:
pass
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
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 _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
if _IS_WIN:
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)
return
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()