airmouse-core 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airmouse/__init__.py +21 -0
- airmouse/cli.py +201 -0
- airmouse/py.typed +1 -0
- airmouse/server.py +442 -0
- airmouse_core-2.0.0.dist-info/METADATA +247 -0
- airmouse_core-2.0.0.dist-info/RECORD +22 -0
- airmouse_core-2.0.0.dist-info/WHEEL +5 -0
- airmouse_core-2.0.0.dist-info/entry_points.txt +2 -0
- airmouse_core-2.0.0.dist-info/licenses/LICENSE +21 -0
- airmouse_core-2.0.0.dist-info/top_level.txt +5 -0
- core/actuator.py +64 -0
- core/jitter_buffer.py +91 -0
- core/mouse_controller.py +69 -0
- core/one_euro.py +67 -0
- network/protocol.py +286 -0
- network/tcp_server.py +214 -0
- network/udp_discovery.py +102 -0
- network/ws_server.py +128 -0
- security/auth.py +132 -0
- security/tls_manager.py +145 -0
- ui/app.py +210 -0
- ui/tray_manager.py +50 -0
airmouse/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AirMouse Core Library
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Production-grade Python package for AirMouse servers and input processing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from core.jitter_buffer import UDPJitterBuffer
|
|
8
|
+
from core.one_euro import OneEuroFilter
|
|
9
|
+
from network.protocol import ProtocolParser
|
|
10
|
+
from security.auth import AuthManager
|
|
11
|
+
from airmouse.server import AirMouseServer
|
|
12
|
+
|
|
13
|
+
__version__ = "2.0.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AirMouseServer",
|
|
17
|
+
"OneEuroFilter",
|
|
18
|
+
"UDPJitterBuffer",
|
|
19
|
+
"AuthManager",
|
|
20
|
+
"ProtocolParser",
|
|
21
|
+
]
|
airmouse/cli.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
airmouse.cli
|
|
3
|
+
------------
|
|
4
|
+
Command Line Interface for AirMouse server.
|
|
5
|
+
Generates terminal QR code for zero-friction client pairing and runs the server out-of-the-box.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import qrcode
|
|
20
|
+
|
|
21
|
+
from airmouse.server import AirMouseServer
|
|
22
|
+
from security.auth import AuthManager
|
|
23
|
+
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=logging.INFO,
|
|
26
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
27
|
+
)
|
|
28
|
+
logger = logging.getLogger("AirMouseCLI")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_local_ip() -> str:
|
|
32
|
+
"""Best-effort discovery of non-loopback LAN IP address."""
|
|
33
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
34
|
+
try:
|
|
35
|
+
# Does not actually connect but picks the outgoing route interface
|
|
36
|
+
s.connect(("8.8.8.8", 80))
|
|
37
|
+
ip = s.getsockname()[0]
|
|
38
|
+
except Exception:
|
|
39
|
+
ip = "127.0.0.1"
|
|
40
|
+
finally:
|
|
41
|
+
s.close()
|
|
42
|
+
return ip
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def display_terminal_qr(data: str) -> None:
|
|
46
|
+
"""Prints an ASCII/ANSI QR code directly in the terminal."""
|
|
47
|
+
qr = qrcode.QRCode(
|
|
48
|
+
version=1,
|
|
49
|
+
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
|
50
|
+
box_size=1,
|
|
51
|
+
border=1,
|
|
52
|
+
)
|
|
53
|
+
qr.add_data(data)
|
|
54
|
+
qr.make(fit=True)
|
|
55
|
+
|
|
56
|
+
f = io.StringIO()
|
|
57
|
+
qr.print_ascii(out=f, invert=True)
|
|
58
|
+
f.seek(0)
|
|
59
|
+
print(f.read())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_args(argv=None) -> argparse.Namespace:
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
prog="airmouse-server",
|
|
65
|
+
description="AirMouse Server - Zero-latency, TLS-secured remote input server",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--host",
|
|
69
|
+
type=str,
|
|
70
|
+
default="0.0.0.0",
|
|
71
|
+
help="IP address to bind to (default: 0.0.0.0)",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--port",
|
|
75
|
+
type=int,
|
|
76
|
+
default=8080,
|
|
77
|
+
help="TCP listening port (default: 8080)",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--ws-port",
|
|
81
|
+
type=int,
|
|
82
|
+
default=8081,
|
|
83
|
+
help="WebSocket listening port (default: 8081)",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--udp-port",
|
|
87
|
+
type=int,
|
|
88
|
+
default=8083,
|
|
89
|
+
help="UDP discovery broadcast listening port (default: 8083)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--no-tls",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Disable TLS encryption (TLS 1.2+ is enabled by default)",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--no-smoothing",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Disable 1€ adaptive low-pass filter smoothing",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--sensitivity",
|
|
103
|
+
type=float,
|
|
104
|
+
default=1.0,
|
|
105
|
+
help="Mouse speed multiplier (default: 1.0)",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--qr",
|
|
109
|
+
action="store_true",
|
|
110
|
+
help="Display terminal QR code for instant Android client pairing",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--name",
|
|
114
|
+
type=str,
|
|
115
|
+
default="AirMouse Server",
|
|
116
|
+
help="Friendly server display name",
|
|
117
|
+
)
|
|
118
|
+
return parser.parse_args(argv)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def run_server(args: argparse.Namespace) -> None:
|
|
122
|
+
auth_mgr = AuthManager()
|
|
123
|
+
token = auth_mgr.generate_token("cli_client")
|
|
124
|
+
lan_ip = get_local_ip()
|
|
125
|
+
|
|
126
|
+
enable_tls = not args.no_tls
|
|
127
|
+
|
|
128
|
+
pairing_payload = {
|
|
129
|
+
"host": lan_ip,
|
|
130
|
+
"tcp_port": args.port,
|
|
131
|
+
"ws_port": args.ws_port,
|
|
132
|
+
"token": token,
|
|
133
|
+
"tls": enable_tls,
|
|
134
|
+
"name": args.name,
|
|
135
|
+
}
|
|
136
|
+
pairing_json = json.dumps(pairing_payload)
|
|
137
|
+
|
|
138
|
+
print("=" * 60)
|
|
139
|
+
print(f" {args.name}")
|
|
140
|
+
print(f" LAN Address: {lan_ip}")
|
|
141
|
+
print(f" TCP Port : {args.port} (TLS: {enable_tls})")
|
|
142
|
+
print(f" WS Port : {args.ws_port} (TLS: {enable_tls})")
|
|
143
|
+
print(f" UDP Port : {args.udp_port}")
|
|
144
|
+
print(f" Pairing Token: {token}")
|
|
145
|
+
print("=" * 60)
|
|
146
|
+
|
|
147
|
+
if args.qr:
|
|
148
|
+
print("\nScan this QR code with the AirMouse Android App to pair:\n")
|
|
149
|
+
try:
|
|
150
|
+
display_terminal_qr(pairing_json)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.warning(f"Could not print QR code to terminal: {e}")
|
|
153
|
+
|
|
154
|
+
server = AirMouseServer(
|
|
155
|
+
host=args.host,
|
|
156
|
+
tcp_port=args.port,
|
|
157
|
+
ws_port=args.ws_port,
|
|
158
|
+
udp_port=args.udp_port,
|
|
159
|
+
enable_tls=enable_tls,
|
|
160
|
+
auth_manager=auth_mgr,
|
|
161
|
+
sensitivity=args.sensitivity,
|
|
162
|
+
smoothing=not args.no_smoothing,
|
|
163
|
+
server_name=args.name,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
server.on_connect(lambda s_id: logger.info(f"[Event] Client Connected: {s_id}"))
|
|
167
|
+
server.on_disconnect(lambda s_id: logger.info(f"[Event] Client Disconnected: {s_id}"))
|
|
168
|
+
server.on_click(lambda btn: logger.debug(f"[Event] Mouse Click: {btn}"))
|
|
169
|
+
|
|
170
|
+
await server.start()
|
|
171
|
+
logger.info("AirMouse server started successfully and ready for connections.")
|
|
172
|
+
|
|
173
|
+
stop_event = asyncio.Event()
|
|
174
|
+
|
|
175
|
+
def _shutdown():
|
|
176
|
+
logger.info("Shutdown signal received, shutting down gracefully...")
|
|
177
|
+
stop_event.set()
|
|
178
|
+
|
|
179
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
180
|
+
try:
|
|
181
|
+
asyncio.get_running_loop().add_signal_handler(sig, _shutdown)
|
|
182
|
+
except NotImplementedError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
await stop_event.wait()
|
|
187
|
+
finally:
|
|
188
|
+
await server.stop()
|
|
189
|
+
logger.info("AirMouse server shut down cleanly.")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv=None) -> None:
|
|
193
|
+
args = parse_args(argv)
|
|
194
|
+
try:
|
|
195
|
+
asyncio.run(run_server(args))
|
|
196
|
+
except KeyboardInterrupt:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|
airmouse/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561 type checking.
|
airmouse/server.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""
|
|
2
|
+
airmouse.server
|
|
3
|
+
---------------
|
|
4
|
+
Asyncio TCP & WebSocket dual-stack server with TLS 1.2+ encryption,
|
|
5
|
+
UDP discovery responder with challenge-response nonces,
|
|
6
|
+
thread-safe mouse command queue and actuator thread,
|
|
7
|
+
and event callbacks: on_connect, on_disconnect, on_move, on_click.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import queue
|
|
13
|
+
import socket
|
|
14
|
+
import ssl
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
|
18
|
+
|
|
19
|
+
from core.actuator import MouseActuatorThread
|
|
20
|
+
from core.mouse_controller import MouseController
|
|
21
|
+
from network.protocol import (
|
|
22
|
+
BoundsViolationError,
|
|
23
|
+
ClickPayload,
|
|
24
|
+
HelloPayload,
|
|
25
|
+
MovePayload,
|
|
26
|
+
ProtocolParser,
|
|
27
|
+
ReplayAttackError,
|
|
28
|
+
SchemaViolationError,
|
|
29
|
+
ScrollPayload,
|
|
30
|
+
)
|
|
31
|
+
from network.udp_discovery import UDPDiscoveryServer
|
|
32
|
+
from security.auth import AuthManager
|
|
33
|
+
from security.tls_manager import TLSManager
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import websockets
|
|
37
|
+
import websockets.server
|
|
38
|
+
WEBSOCKETS_AVAILABLE = True
|
|
39
|
+
except ImportError:
|
|
40
|
+
WEBSOCKETS_AVAILABLE = False
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("AirMouseServer")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AirMouseServer:
|
|
46
|
+
"""
|
|
47
|
+
High-level AirMouse server combining TCP, WebSocket, and UDP discovery services.
|
|
48
|
+
Decouples networking from OS mouse actuation using an internal thread-safe queue.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
host: str = "0.0.0.0",
|
|
54
|
+
tcp_port: int = 8080,
|
|
55
|
+
ws_port: int = 8081,
|
|
56
|
+
udp_port: int = 8083,
|
|
57
|
+
enable_tls: bool = True,
|
|
58
|
+
auth_manager: Optional[AuthManager] = None,
|
|
59
|
+
tls_manager: Optional[TLSManager] = None,
|
|
60
|
+
sensitivity: float = 1.0,
|
|
61
|
+
smoothing: bool = True,
|
|
62
|
+
server_name: str = "AirMouse Desktop Server",
|
|
63
|
+
) -> None:
|
|
64
|
+
self.host = host
|
|
65
|
+
self.tcp_port = tcp_port
|
|
66
|
+
self.ws_port = ws_port
|
|
67
|
+
self.udp_port = udp_port
|
|
68
|
+
self.enable_tls = enable_tls
|
|
69
|
+
self.server_name = server_name
|
|
70
|
+
|
|
71
|
+
self.auth_manager = auth_manager or AuthManager()
|
|
72
|
+
self.tls_manager = tls_manager or (TLSManager() if enable_tls else None)
|
|
73
|
+
|
|
74
|
+
# Thread-safe queue for OS mouse actuation
|
|
75
|
+
self.mouse_queue: queue.Queue = queue.Queue()
|
|
76
|
+
self.actuator = MouseActuatorThread(
|
|
77
|
+
command_queue=self.mouse_queue,
|
|
78
|
+
sensitivity=sensitivity,
|
|
79
|
+
smoothing=smoothing,
|
|
80
|
+
)
|
|
81
|
+
self._actuator_thread: Optional[threading.Thread] = None
|
|
82
|
+
|
|
83
|
+
# Protocol parser with per-session sequence and replay attack checks
|
|
84
|
+
self.parser = ProtocolParser(replay_window_ms=5000)
|
|
85
|
+
|
|
86
|
+
# UDP Discovery
|
|
87
|
+
self.udp_discovery: Optional[UDPDiscoveryServer] = None
|
|
88
|
+
|
|
89
|
+
# Server handles
|
|
90
|
+
self._tcp_server: Optional[asyncio.Server] = None
|
|
91
|
+
self._ws_server: Optional[Any] = None
|
|
92
|
+
self._is_running = False
|
|
93
|
+
|
|
94
|
+
# Session tracking
|
|
95
|
+
self.authenticated_sessions: Set[str] = set()
|
|
96
|
+
self.active_tcp_writers: Dict[str, asyncio.StreamWriter] = {}
|
|
97
|
+
|
|
98
|
+
# Event callbacks
|
|
99
|
+
self._on_connect_callbacks: list[Callable[[str], None]] = []
|
|
100
|
+
self._on_disconnect_callbacks: list[Callable[[str], None]] = []
|
|
101
|
+
self._on_move_callbacks: list[Callable[[float, float], None]] = []
|
|
102
|
+
self._on_click_callbacks: list[Callable[[str], None]] = []
|
|
103
|
+
|
|
104
|
+
def on_connect(self, callback: Callable[[str], None]) -> None:
|
|
105
|
+
"""Register a callback invoked when a client connects and authenticates."""
|
|
106
|
+
self._on_connect_callbacks.append(callback)
|
|
107
|
+
|
|
108
|
+
def on_disconnect(self, callback: Callable[[str], None]) -> None:
|
|
109
|
+
"""Register a callback invoked when a client disconnects."""
|
|
110
|
+
self._on_disconnect_callbacks.append(callback)
|
|
111
|
+
|
|
112
|
+
def on_move(self, callback: Callable[[float, float], None]) -> None:
|
|
113
|
+
"""Register a callback invoked when a motion event is processed."""
|
|
114
|
+
self._on_move_callbacks.append(callback)
|
|
115
|
+
|
|
116
|
+
def on_click(self, callback: Callable[[str], None]) -> None:
|
|
117
|
+
"""Register a callback invoked when a mouse click event is processed."""
|
|
118
|
+
self._on_click_callbacks.append(callback)
|
|
119
|
+
|
|
120
|
+
def _trigger_connect(self, session_id: str) -> None:
|
|
121
|
+
for cb in self._on_connect_callbacks:
|
|
122
|
+
try:
|
|
123
|
+
cb(session_id)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(f"Error in on_connect callback: {e}")
|
|
126
|
+
|
|
127
|
+
def _trigger_disconnect(self, session_id: str) -> None:
|
|
128
|
+
for cb in self._on_disconnect_callbacks:
|
|
129
|
+
try:
|
|
130
|
+
cb(session_id)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Error in on_disconnect callback: {e}")
|
|
133
|
+
|
|
134
|
+
def _trigger_move(self, dx: float, dy: float) -> None:
|
|
135
|
+
for cb in self._on_move_callbacks:
|
|
136
|
+
try:
|
|
137
|
+
cb(dx, dy)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error(f"Error in on_move callback: {e}")
|
|
140
|
+
|
|
141
|
+
def _trigger_click(self, button: str) -> None:
|
|
142
|
+
for cb in self._on_click_callbacks:
|
|
143
|
+
try:
|
|
144
|
+
cb(button)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error(f"Error in on_click callback: {e}")
|
|
147
|
+
|
|
148
|
+
async def _handle_tcp_client(
|
|
149
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
150
|
+
) -> None:
|
|
151
|
+
peer = writer.get_extra_info("peername")
|
|
152
|
+
session_id = f"{peer[0]}:{peer[1]}" if peer else f"tcp_{id(writer)}"
|
|
153
|
+
self.active_tcp_writers[session_id] = writer
|
|
154
|
+
logger.info(f"TCP client connected from {session_id}")
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
while True:
|
|
158
|
+
line = await reader.readline()
|
|
159
|
+
if not line:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
raw_json = line.decode("utf-8").strip()
|
|
163
|
+
if not raw_json:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
msg_type, payload, req_id, seq = self.parser.parse_message(
|
|
168
|
+
raw_json, session_id
|
|
169
|
+
)
|
|
170
|
+
except ReplayAttackError as re:
|
|
171
|
+
logger.warning(f"Replay rejected for {session_id}: {re}")
|
|
172
|
+
nack = self.parser.serialize_response(
|
|
173
|
+
"nack", {"nack_id": "", "error": "replay_detected"}, seq=0
|
|
174
|
+
)
|
|
175
|
+
writer.write(nack.encode("utf-8"))
|
|
176
|
+
await writer.drain()
|
|
177
|
+
continue
|
|
178
|
+
except (SchemaViolationError, BoundsViolationError) as se:
|
|
179
|
+
logger.warning(f"Schema violation from {session_id}: {se}")
|
|
180
|
+
nack = self.parser.serialize_response(
|
|
181
|
+
"nack", {"nack_id": "", "error": str(se)}, seq=0
|
|
182
|
+
)
|
|
183
|
+
writer.write(nack.encode("utf-8"))
|
|
184
|
+
await writer.drain()
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Authentication check
|
|
188
|
+
if session_id not in self.authenticated_sessions:
|
|
189
|
+
if msg_type != "hello" or not isinstance(payload, HelloPayload):
|
|
190
|
+
logger.warning(f"Unauthenticated packet {msg_type} from {session_id}")
|
|
191
|
+
nack = self.parser.serialize_response(
|
|
192
|
+
"nack", {"nack_id": req_id or "", "error": "unauthenticated"}, seq=seq
|
|
193
|
+
)
|
|
194
|
+
writer.write(nack.encode("utf-8"))
|
|
195
|
+
await writer.drain()
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
if not self.auth_manager.validate_token(payload.token):
|
|
199
|
+
logger.warning(f"Invalid auth token from {session_id}")
|
|
200
|
+
nack = self.parser.serialize_response(
|
|
201
|
+
"nack", {"nack_id": req_id or "", "error": "invalid_token"}, seq=seq
|
|
202
|
+
)
|
|
203
|
+
writer.write(nack.encode("utf-8"))
|
|
204
|
+
await writer.drain()
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
self.authenticated_sessions.add(session_id)
|
|
208
|
+
logger.info(f"Client {session_id} successfully authenticated ({payload.name})")
|
|
209
|
+
self._trigger_connect(session_id)
|
|
210
|
+
|
|
211
|
+
welcome = self.parser.serialize_response(
|
|
212
|
+
"welcome",
|
|
213
|
+
{
|
|
214
|
+
"server": self.server_name,
|
|
215
|
+
"version": "2.0.0",
|
|
216
|
+
"client_id": session_id,
|
|
217
|
+
"approved": True,
|
|
218
|
+
},
|
|
219
|
+
seq=seq,
|
|
220
|
+
req_id=req_id,
|
|
221
|
+
)
|
|
222
|
+
writer.write(welcome.encode("utf-8"))
|
|
223
|
+
await writer.drain()
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Authenticated event dispatch
|
|
227
|
+
if msg_type == "move" and isinstance(payload, MovePayload):
|
|
228
|
+
self.mouse_queue.put(("move", payload.dx, payload.dy))
|
|
229
|
+
self._trigger_move(payload.dx, payload.dy)
|
|
230
|
+
|
|
231
|
+
elif msg_type == "click" and isinstance(payload, ClickPayload):
|
|
232
|
+
self.mouse_queue.put(("click", payload.button))
|
|
233
|
+
self._trigger_click(payload.button)
|
|
234
|
+
ack = self.parser.serialize_response(
|
|
235
|
+
"ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
|
|
236
|
+
)
|
|
237
|
+
writer.write(ack.encode("utf-8"))
|
|
238
|
+
await writer.drain()
|
|
239
|
+
|
|
240
|
+
elif msg_type == "scroll" and isinstance(payload, ScrollPayload):
|
|
241
|
+
self.mouse_queue.put(("scroll", payload.delta))
|
|
242
|
+
ack = self.parser.serialize_response(
|
|
243
|
+
"ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
|
|
244
|
+
)
|
|
245
|
+
writer.write(ack.encode("utf-8"))
|
|
246
|
+
await writer.drain()
|
|
247
|
+
|
|
248
|
+
elif msg_type == "ping":
|
|
249
|
+
pong = self.parser.serialize_response("pong", {}, seq=seq, req_id=req_id)
|
|
250
|
+
writer.write(pong.encode("utf-8"))
|
|
251
|
+
await writer.drain()
|
|
252
|
+
|
|
253
|
+
except asyncio.CancelledError:
|
|
254
|
+
pass
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f"TCP exception on {session_id}: {e}")
|
|
257
|
+
finally:
|
|
258
|
+
self.active_tcp_writers.pop(session_id, None)
|
|
259
|
+
was_auth = session_id in self.authenticated_sessions
|
|
260
|
+
self.authenticated_sessions.discard(session_id)
|
|
261
|
+
self.parser.reset_session(session_id)
|
|
262
|
+
try:
|
|
263
|
+
writer.close()
|
|
264
|
+
await writer.wait_closed()
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
if was_auth:
|
|
268
|
+
self._trigger_disconnect(session_id)
|
|
269
|
+
logger.info(f"TCP connection closed for {session_id}")
|
|
270
|
+
|
|
271
|
+
async def _handle_ws_client(self, websocket: Any) -> None:
|
|
272
|
+
session_id = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}"
|
|
273
|
+
logger.info(f"WebSocket client connected from {session_id}")
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
async for raw_msg in websocket:
|
|
277
|
+
try:
|
|
278
|
+
msg_type, payload, req_id, seq = self.parser.parse_message(
|
|
279
|
+
raw_msg, session_id
|
|
280
|
+
)
|
|
281
|
+
except ReplayAttackError:
|
|
282
|
+
resp = self.parser.serialize_response(
|
|
283
|
+
"nack", {"nack_id": "", "error": "replay_detected"}, seq=0
|
|
284
|
+
)
|
|
285
|
+
await websocket.send(resp.strip())
|
|
286
|
+
continue
|
|
287
|
+
except (SchemaViolationError, BoundsViolationError) as se:
|
|
288
|
+
resp = self.parser.serialize_response(
|
|
289
|
+
"nack", {"nack_id": "", "error": str(se)}, seq=0
|
|
290
|
+
)
|
|
291
|
+
await websocket.send(resp.strip())
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
if session_id not in self.authenticated_sessions:
|
|
295
|
+
if msg_type != "hello" or not isinstance(payload, HelloPayload):
|
|
296
|
+
resp = self.parser.serialize_response(
|
|
297
|
+
"nack", {"nack_id": req_id or "", "error": "unauthenticated"}, seq=seq
|
|
298
|
+
)
|
|
299
|
+
await websocket.send(resp.strip())
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
if not self.auth_manager.validate_token(payload.token):
|
|
303
|
+
resp = self.parser.serialize_response(
|
|
304
|
+
"nack", {"nack_id": req_id or "", "error": "invalid_token"}, seq=seq
|
|
305
|
+
)
|
|
306
|
+
await websocket.send(resp.strip())
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
self.authenticated_sessions.add(session_id)
|
|
310
|
+
self._trigger_connect(session_id)
|
|
311
|
+
|
|
312
|
+
welcome = self.parser.serialize_response(
|
|
313
|
+
"welcome",
|
|
314
|
+
{
|
|
315
|
+
"server": self.server_name,
|
|
316
|
+
"version": "2.0.0",
|
|
317
|
+
"client_id": session_id,
|
|
318
|
+
"approved": True,
|
|
319
|
+
},
|
|
320
|
+
seq=seq,
|
|
321
|
+
req_id=req_id,
|
|
322
|
+
)
|
|
323
|
+
await websocket.send(welcome.strip())
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
if msg_type == "move" and isinstance(payload, MovePayload):
|
|
327
|
+
self.mouse_queue.put(("move", payload.dx, payload.dy))
|
|
328
|
+
self._trigger_move(payload.dx, payload.dy)
|
|
329
|
+
|
|
330
|
+
elif msg_type == "click" and isinstance(payload, ClickPayload):
|
|
331
|
+
self.mouse_queue.put(("click", payload.button))
|
|
332
|
+
self._trigger_click(payload.button)
|
|
333
|
+
ack = self.parser.serialize_response(
|
|
334
|
+
"ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
|
|
335
|
+
)
|
|
336
|
+
await websocket.send(ack.strip())
|
|
337
|
+
|
|
338
|
+
elif msg_type == "scroll" and isinstance(payload, ScrollPayload):
|
|
339
|
+
self.mouse_queue.put(("scroll", payload.delta))
|
|
340
|
+
ack = self.parser.serialize_response(
|
|
341
|
+
"ack", {"ack_id": req_id or "", "status": "ok"}, seq=seq
|
|
342
|
+
)
|
|
343
|
+
await websocket.send(ack.strip())
|
|
344
|
+
|
|
345
|
+
elif msg_type == "ping":
|
|
346
|
+
pong = self.parser.serialize_response("pong", {}, seq=seq, req_id=req_id)
|
|
347
|
+
await websocket.send(pong.strip())
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.debug(f"WS connection error: {e}")
|
|
351
|
+
finally:
|
|
352
|
+
was_auth = session_id in self.authenticated_sessions
|
|
353
|
+
self.authenticated_sessions.discard(session_id)
|
|
354
|
+
self.parser.reset_session(session_id)
|
|
355
|
+
if was_auth:
|
|
356
|
+
self._trigger_disconnect(session_id)
|
|
357
|
+
logger.info(f"WS client disconnected: {session_id}")
|
|
358
|
+
|
|
359
|
+
async def start(self) -> None:
|
|
360
|
+
"""Starts mouse actuator thread, UDP discovery, TCP server, and WebSocket server."""
|
|
361
|
+
if self._is_running:
|
|
362
|
+
return
|
|
363
|
+
self._is_running = True
|
|
364
|
+
|
|
365
|
+
# 1. Start mouse actuator worker thread
|
|
366
|
+
self._actuator_thread = threading.Thread(
|
|
367
|
+
target=self.actuator.start,
|
|
368
|
+
name="AirMouse-ActuatorThread",
|
|
369
|
+
daemon=True,
|
|
370
|
+
)
|
|
371
|
+
self._actuator_thread.start()
|
|
372
|
+
|
|
373
|
+
# 2. Setup TLS context if enabled
|
|
374
|
+
ssl_ctx: Optional[ssl.SSLContext] = None
|
|
375
|
+
if self.enable_tls:
|
|
376
|
+
if not self.tls_manager:
|
|
377
|
+
self.tls_manager = TLSManager()
|
|
378
|
+
ssl_ctx = self.tls_manager.get_server_ssl_context()
|
|
379
|
+
|
|
380
|
+
# 3. Start UDP discovery responder
|
|
381
|
+
if self.udp_port > 0:
|
|
382
|
+
self.udp_discovery = UDPDiscoveryServer(
|
|
383
|
+
port=self.udp_port,
|
|
384
|
+
server_name=self.server_name,
|
|
385
|
+
tcp_port=self.tcp_port,
|
|
386
|
+
ws_port=self.ws_port,
|
|
387
|
+
tls_enabled=self.enable_tls,
|
|
388
|
+
)
|
|
389
|
+
self.udp_discovery.start()
|
|
390
|
+
logger.info(f"UDP Discovery service started on port {self.udp_port}")
|
|
391
|
+
|
|
392
|
+
# 4. Start TCP server
|
|
393
|
+
self._tcp_server = await asyncio.start_server(
|
|
394
|
+
self._handle_tcp_client,
|
|
395
|
+
self.host,
|
|
396
|
+
self.tcp_port,
|
|
397
|
+
ssl=ssl_ctx,
|
|
398
|
+
)
|
|
399
|
+
logger.info(
|
|
400
|
+
f"TCP Protocol server listening on {self.host}:{self.tcp_port} (TLS={self.enable_tls})"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# 5. Start WebSocket server
|
|
404
|
+
if WEBSOCKETS_AVAILABLE and self.ws_port > 0:
|
|
405
|
+
self._ws_server = await websockets.serve(
|
|
406
|
+
self._handle_ws_client,
|
|
407
|
+
self.host,
|
|
408
|
+
self.ws_port,
|
|
409
|
+
ssl=ssl_ctx,
|
|
410
|
+
)
|
|
411
|
+
logger.info(
|
|
412
|
+
f"WebSocket server listening on {self.host}:{self.ws_port} (TLS={self.enable_tls})"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
async def stop(self) -> None:
|
|
416
|
+
"""Stops all servers, UDP discovery, and the mouse actuator worker thread."""
|
|
417
|
+
self._is_running = False
|
|
418
|
+
|
|
419
|
+
# Close TCP server
|
|
420
|
+
if self._tcp_server:
|
|
421
|
+
self._tcp_server.close()
|
|
422
|
+
await self._tcp_server.wait_closed()
|
|
423
|
+
self._tcp_server = None
|
|
424
|
+
|
|
425
|
+
# Close WebSocket server
|
|
426
|
+
if self._ws_server:
|
|
427
|
+
self._ws_server.close()
|
|
428
|
+
await self._ws_server.wait_closed()
|
|
429
|
+
self._ws_server = None
|
|
430
|
+
|
|
431
|
+
# Stop UDP discovery
|
|
432
|
+
if self.udp_discovery:
|
|
433
|
+
self.udp_discovery.stop()
|
|
434
|
+
self.udp_discovery = None
|
|
435
|
+
|
|
436
|
+
# Stop mouse actuator thread
|
|
437
|
+
self.actuator.stop()
|
|
438
|
+
if self._actuator_thread and self._actuator_thread.is_alive():
|
|
439
|
+
self._actuator_thread.join(timeout=1.0)
|
|
440
|
+
self._actuator_thread = None
|
|
441
|
+
|
|
442
|
+
logger.info("AirMouseServer stopped cleanly.")
|