Mercurial > libervia-backend
comparison libervia/cli/cmd_remote_control.py @ 4243:e47e29511d57
cli (remote-control): new `remote-control` command:
2 subcommands are available: `send` to control remotely a device, and `receive` to be
controlled.
Documentation will follow.
rel 436
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 11 May 2024 13:52:43 +0200 |
parents | |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4242:8acf46ed7f36 | 4243:e47e29511d57 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 | |
4 # Libervia CLI | |
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 | |
21 import asyncio | |
22 from functools import partial | |
23 import logging | |
24 import xml.etree.ElementTree as ET | |
25 | |
26 from prompt_toolkit.input import Input, create_input | |
27 from prompt_toolkit.key_binding import KeyPress | |
28 from prompt_toolkit.keys import Keys | |
29 from prompt_toolkit.patch_stdout import patch_stdout | |
30 from prompt_toolkit import PromptSession | |
31 | |
32 from libervia.backend.core import exceptions | |
33 from libervia.backend.core.i18n import _ | |
34 from libervia.backend.tools.common import data_format | |
35 from libervia.cli.constants import Const as C | |
36 from libervia.frontends.tools import aio, jid | |
37 from libervia.frontends.tools.webrtc_remote_control import WebRTCRemoteController | |
38 | |
39 from . import base | |
40 from . import xmlui_manager | |
41 | |
42 __commands__ = ["RemoteControl"] | |
43 | |
44 | |
45 class Send(base.CommandBase): | |
46 def __init__(self, host): | |
47 super(Send, self).__init__( | |
48 host, | |
49 "send", | |
50 use_progress=True, | |
51 use_verbose=True, | |
52 help=_("remote control another device"), | |
53 ) | |
54 self.remote_controller: WebRTCRemoteController | None = None | |
55 | |
56 def add_parser_options(self): | |
57 self.parser.add_argument("jid", help=_("the destination jid")) | |
58 | |
59 def send_key( | |
60 self, | |
61 key: str, | |
62 code: str, | |
63 ctrl_key: bool = False, | |
64 shift_key: bool = False, | |
65 alt_key: bool = False, | |
66 meta_key: bool = False, | |
67 ) -> None: | |
68 """ | |
69 Send the key press input. | |
70 | |
71 @param key: The key pressed. | |
72 @param code: The code of the key pressed. | |
73 @param ctrl_key: Whether the Ctrl key was pressed. | |
74 @param shift_key: Whether the Shift key was pressed. | |
75 @param alt_key: Whether the Alt key was pressed. | |
76 @param meta_key: Whether the Meta key was pressed. | |
77 """ | |
78 assert self.remote_controller is not None | |
79 event_data = { | |
80 "key": key, | |
81 "code": code, | |
82 "ctrlKey": ctrl_key, | |
83 "shiftKey": shift_key, | |
84 "altKey": alt_key, | |
85 "metaKey": meta_key, | |
86 } | |
87 # we send both events as we don't distinguish them. | |
88 for evt_type in ["keydown", "keyup"]: | |
89 key_data = {"type": evt_type, **event_data} | |
90 self.remote_controller.send_input(key_data) | |
91 print(f"Sending {key_data}") | |
92 | |
93 def handle_key_press(self, key_press: KeyPress) -> None: | |
94 """Handle key press event.""" | |
95 key = key_press.key | |
96 if key.startswith("c-"): | |
97 key = key[2:] # remove "c-" prefix | |
98 elif key.startswith("s-"): | |
99 key = key[2:] # remove "s-" prefix | |
100 self.send_key( | |
101 key=key.lower(), | |
102 # FIXME: handle properly code. | |
103 code=key.lower(), | |
104 ctrl_key=key_press.key.startswith("c-"), | |
105 shift_key=key_press.key.startswith("s-") or key.isupper(), | |
106 # FIXME: alt-key is translated to escape + key, see | |
107 # https://python-prompt-toolkit.readthedocs.io/en/master/pages/advanced_topics/key_bindings.html | |
108 alt_key=False, | |
109 # TODO | |
110 meta_key=False, | |
111 ) | |
112 | |
113 def on_keys_ready(self, input_: Input, handle_key_fut: asyncio.Future) -> None: | |
114 for key_press in input_.read_keys(): | |
115 if key_press.key == Keys.ControlC: | |
116 handle_key_fut.set_exception(KeyboardInterrupt()) | |
117 else: | |
118 self.handle_key_press(key_press) | |
119 | |
120 async def confirm_ctrl_c(self) -> bool: | |
121 """Ask user if they want to send Ctrl-C event or quit.""" | |
122 session = PromptSession() | |
123 with patch_stdout(): | |
124 while True: | |
125 response = await session.prompt_async( | |
126 "Ctrl-C pressed. Send event (e) or quit (q)? (e/q): " | |
127 ) | |
128 if response.lower() == "e": | |
129 return True | |
130 elif response.lower() == "q": | |
131 return False | |
132 | |
133 async def handle_ctrl_c(self) -> None: | |
134 """Handle Ctrl-C key press.""" | |
135 if await self.confirm_ctrl_c(): | |
136 self.send_key(key="c", code="c", ctrl_key=True) | |
137 else: | |
138 await self.host.a_quit() | |
139 | |
140 async def _on_open(self, remote_controller: WebRTCRemoteController) -> None: | |
141 input_ = create_input() | |
142 self.disp( | |
143 "Connection with peer established. Your keyboard input will be sent to " | |
144 "controlled device." | |
145 ) | |
146 | |
147 while True: | |
148 handle_key_fut = asyncio.Future() | |
149 try: | |
150 with input_.raw_mode(): | |
151 with input_.attach( | |
152 partial(self.on_keys_ready, input_, handle_key_fut) | |
153 ): | |
154 await handle_key_fut | |
155 except KeyboardInterrupt: | |
156 await self.handle_ctrl_c() | |
157 | |
158 async def start(self): | |
159 root_logger = logging.getLogger() | |
160 # we don't want any formatting for messages from webrtc | |
161 for handler in root_logger.handlers: | |
162 handler.setFormatter(None) | |
163 if self.verbosity == 0: | |
164 root_logger.setLevel(logging.ERROR) | |
165 if self.verbosity >= 1: | |
166 root_logger.setLevel(logging.WARNING) | |
167 if self.verbosity >= 2: | |
168 root_logger.setLevel(logging.DEBUG) | |
169 | |
170 aio.install_glib_asyncio_iteration() | |
171 self.remote_controller = WebRTCRemoteController( | |
172 self.host.bridge, self.profile, end_call_cb=self.host.a_quit | |
173 ) | |
174 await self.remote_controller.start( | |
175 self.args.jid, {"devices": {"keyboard": {}}}, on_open_cb=self._on_open | |
176 ) | |
177 | |
178 | |
179 class Receive(base.CommandAnswering): | |
180 def __init__(self, host): | |
181 super().__init__( | |
182 host, | |
183 "receive", | |
184 use_verbose=True, | |
185 help=_("be remote controlled by another device"), | |
186 ) | |
187 self.action_callbacks = { | |
188 C.META_TYPE_CONFIRM: self.on_confirm_action, | |
189 C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_confirm_action, | |
190 C.META_TYPE_REMOTE_CONTROL: self.on_remote_control_action, | |
191 } | |
192 self.receiver = None | |
193 | |
194 def add_parser_options(self): | |
195 self.parser.add_argument( | |
196 "-S", | |
197 "--share-screen", | |
198 choices=["yes", "no", "auto"], | |
199 default="auto", | |
200 help=_("share the screen (default: auto)"), | |
201 ) | |
202 self.parser.add_argument( | |
203 "jids", | |
204 nargs="+", | |
205 help=_("jids accepted automatically"), | |
206 ) | |
207 | |
208 async def start_webrtc( | |
209 self, from_jid: jid.JID, session_id: str, screenshare: dict, devices: dict | |
210 ) -> None: | |
211 """Start the WebRTC workflown""" | |
212 assert self.receiver is not None | |
213 root_logger = logging.getLogger() | |
214 # we don't want any formatting for messages from webrtc | |
215 for handler in root_logger.handlers: | |
216 handler.setFormatter(None) | |
217 if self.verbosity == 0: | |
218 root_logger.setLevel(logging.ERROR) | |
219 if self.verbosity >= 1: | |
220 root_logger.setLevel(logging.WARNING) | |
221 if self.verbosity >= 2: | |
222 root_logger.setLevel(logging.DEBUG) | |
223 await self.receiver.start_receiving(from_jid, session_id, screenshare) | |
224 | |
225 def get_xmlui_id(self, action_data): | |
226 # FIXME: we temporarily use ElementTree, but a real XMLUI managing module | |
227 # should be available in the futur | |
228 # TODO: XMLUI module | |
229 try: | |
230 xml_ui = action_data["xmlui"] | |
231 except KeyError: | |
232 self.disp(_("Action has no XMLUI"), 1) | |
233 else: | |
234 ui = ET.fromstring(xml_ui.encode("utf-8")) | |
235 xmlui_id = ui.get("submit") | |
236 if not xmlui_id: | |
237 self.disp(_("Invalid XMLUI received"), error=True) | |
238 return xmlui_id | |
239 | |
240 async def get_confirmation( | |
241 self, action_data: dict, pre_accepted: bool = False | |
242 ) -> tuple[str, jid.JID, bool]: | |
243 """Check if action is confirmed, and ask user otherwise | |
244 | |
245 @param action_data: Data as used in on_action_new. | |
246 @return: a tuple with: | |
247 - XMLUI ID | |
248 - sender JID | |
249 - confirmation boolean | |
250 """ | |
251 xmlui_id = self.get_xmlui_id(action_data) | |
252 if xmlui_id is None: | |
253 self.disp("Internal ERROR: xmlui_id missing", error=True) | |
254 raise exceptions.InternalError() | |
255 if ( | |
256 action_data["type"] != C.META_TYPE_REMOTE_CONTROL | |
257 and action_data.get("subtype") != C.META_TYPE_REMOTE_CONTROL | |
258 ): | |
259 self.disp(_("Ignoring confirm dialog unrelated to remote control."), 1) | |
260 raise exceptions.CancelError | |
261 try: | |
262 from_jid = jid.JID(action_data["from_jid"]) | |
263 except ValueError: | |
264 self.disp( | |
265 _('invalid "from_jid" value received, ignoring: {value}').format( | |
266 value=action_data["from_jid"] | |
267 ), | |
268 error=True, | |
269 ) | |
270 raise exceptions.DataError | |
271 except KeyError: | |
272 self.disp(_('ignoring action without "from_jid" value'), error=True) | |
273 raise exceptions.DataError | |
274 | |
275 self.disp(_("Confirmation needed for request from an entity not in roster"), 1) | |
276 | |
277 if pre_accepted: | |
278 # Session has already been accepted during pre-flight. | |
279 confirmed = True | |
280 elif action_data["type"] == C.META_TYPE_CONFIRM and not self.bare_jids: | |
281 # Sender is in roster, and we have an "accept all" policy. | |
282 confirmed = True | |
283 self.disp( | |
284 _( | |
285 f"{from_jid} automatically confirmed due to entity being in roster" | |
286 " and accept all policy." | |
287 ) | |
288 ) | |
289 elif from_jid.bare in self.bare_jids: | |
290 # If the sender is expected, we can confirm the session. | |
291 confirmed = True | |
292 self.disp(_("Sender confirmed because they are explicitly expected"), 1) | |
293 else: | |
294 # Not automatically accepted, we ask authorisation to the user. | |
295 xmlui = xmlui_manager.create(self.host, action_data["xmlui"]) | |
296 confirmed = await self.host.confirm(xmlui.dlg.message) | |
297 return xmlui_id, from_jid, confirmed | |
298 | |
299 async def on_confirm_action(self, action_data, action_id, security_limit, profile): | |
300 """Handle pre-flight remote control request""" | |
301 try: | |
302 xmlui_id, from_jid, confirmed = await self.get_confirmation(action_data) | |
303 except exceptions.InternalError: | |
304 self.host.quit_from_signal(1) | |
305 return | |
306 except (exceptions.InternalError, exceptions.DataError): | |
307 return | |
308 xmlui_data = {"answer": C.bool_const(confirmed)} | |
309 await self.host.bridge.action_launch( | |
310 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile | |
311 ) | |
312 if not confirmed: | |
313 self.disp(_("Session refused for {from_jid}").format(from_jid=from_jid)) | |
314 self.host.quit_from_signal(0) | |
315 | |
316 async def on_remote_control_action( | |
317 self, action_data, action_id, security_limit, profile | |
318 ): | |
319 """Handles actual remote control request""" | |
320 try: | |
321 session_id = action_data["session_id"] | |
322 except KeyError: | |
323 self.disp( | |
324 f"Internal Error: Session ID is missing in action data: {action_data=}", | |
325 error=True, | |
326 ) | |
327 return | |
328 pre_accepted = action_data.get("pre_accepted", False) | |
329 try: | |
330 xmlui_id, from_jid, confirmed = await self.get_confirmation( | |
331 action_data, pre_accepted | |
332 ) | |
333 except exceptions.InternalError: | |
334 self.host.quit_from_signal(1) | |
335 return | |
336 except (exceptions.InternalError, exceptions.DataError): | |
337 return | |
338 if confirmed: | |
339 await self.start_webrtc( | |
340 from_jid, | |
341 session_id, | |
342 action_data.get("screenshare", {}), | |
343 action_data.get("devices", {}), | |
344 ) | |
345 xmlui_data = {"answer": C.bool_const(confirmed)} | |
346 await self.host.bridge.action_launch( | |
347 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile | |
348 ) | |
349 | |
350 async def start(self): | |
351 self.bare_jids = [jid.JID(jid_).bare for jid_ in self.args.jids] | |
352 from libervia.frontends.tools.webrtc_remote_control import ( | |
353 WebRTCRemoteControlReceiver, | |
354 ) | |
355 | |
356 self.receiver = WebRTCRemoteControlReceiver( | |
357 self.host.bridge, | |
358 self.profile, | |
359 on_close_cb=self.host.a_quit, | |
360 verbose=self.verbosity >= 1, | |
361 ) | |
362 aio.install_glib_asyncio_iteration() | |
363 # FIXME: for now AUTO always do the screen sharing, but is should be disabled and | |
364 # appropriate method should be used when no desktop environment is detected. | |
365 with_screen_sharing = self.args.share_screen in ("yes", "auto") | |
366 await self.receiver.request_remote_desktop( | |
367 with_screen_sharing | |
368 ) | |
369 | |
370 self.disp(_("Waiting for controlling device…")) | |
371 await self.start_answering() | |
372 | |
373 | |
374 class RemoteControl(base.CommandBase): | |
375 subcommands = (Send, Receive) | |
376 | |
377 def __init__(self, host): | |
378 super().__init__( | |
379 host, | |
380 "remote-control", | |
381 use_profile=False, | |
382 help=_("Control or be controlled by another device."), | |
383 ) |