3151 lines
114 KiB
Python
3151 lines
114 KiB
Python
"""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()
|