comparison libervia/cli/cmd_file.py @ 4233:d01b8d002619

cli (call, file), frontends: implement webRTC data channel transfer: - file send/receive commands now supports webRTC transfer. In `send` command, the `--webrtc` flags is currenty used to activate it. - WebRTC related code have been factorized and moved to `libervia.frontends.tools.webrtc*` modules. rel 442
author Goffi <goffi@goffi.org>
date Sat, 06 Apr 2024 13:43:09 +0200
parents cd889f4771cb
children 79c8a70e1813
comparison
equal deleted inserted replaced
4232:0fbe5c605eb6 4233:d01b8d002619
16 16
17 # You should have received a copy of the GNU Affero General Public License 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/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 20
21 import asyncio
22 from functools import partial
23 import importlib
24 import logging
25 from typing import IO
21 from . import base 26 from . import base
22 from . import xmlui_manager 27 from . import xmlui_manager
23 import sys 28 import sys
24 import os 29 import os
25 import os.path 30 import os.path
26 import tarfile 31 import tarfile
27 from libervia.backend.core.i18n import _ 32 from libervia.backend.core.i18n import _
28 from libervia.backend.tools.common import data_format 33 from libervia.backend.tools.common import data_format
29 from libervia.cli.constants import Const as C 34 from libervia.cli.constants import Const as C
30 from libervia.cli import common 35 from libervia.cli import common
31 from libervia.frontends.tools import jid 36 from libervia.frontends.tools import aio, jid
32 from libervia.backend.tools.common.ansi import ANSI as A 37 from libervia.backend.tools.common.ansi import ANSI as A
33 from libervia.backend.tools.common import utils 38 from libervia.backend.tools.common import utils
34 from urllib.parse import urlparse 39 from urllib.parse import urlparse
35 from pathlib import Path 40 from pathlib import Path
36 import tempfile 41 import tempfile
79 "-e", 84 "-e",
80 "--encrypt", 85 "--encrypt",
81 action="store_true", 86 action="store_true",
82 help=_("end-to-end encrypt the file transfer") 87 help=_("end-to-end encrypt the file transfer")
83 ) 88 )
89 self.parser.add_argument(
90 "--webrtc",
91 action="store_true",
92 help=_("Use WebRTC Data Channel transport.")
93 )
84 94
85 async def on_progress_started(self, metadata): 95 async def on_progress_started(self, metadata):
86 self.disp(_("File copy started"), 2) 96 self.disp(_("File copy started"), 2)
87 97
88 async def on_progress_finished(self, metadata): 98 async def on_progress_finished(self, metadata):
92 if error_msg == C.PROGRESS_ERROR_DECLINED: 102 if error_msg == C.PROGRESS_ERROR_DECLINED:
93 self.disp(_("The file has been refused by your contact")) 103 self.disp(_("The file has been refused by your contact"))
94 else: 104 else:
95 self.disp(_("Error while sending file: {}").format(error_msg), error=True) 105 self.disp(_("Error while sending file: {}").format(error_msg), error=True)
96 106
97 async def got_id(self, data, file_): 107 async def got_id(self, data: dict):
98 """Called when a progress id has been received 108 """Called when a progress id has been received"""
99
100 @param pid(unicode): progress id
101 @param file_(str): file path
102 """
103 # FIXME: this show progress only for last progress_id 109 # FIXME: this show progress only for last progress_id
104 self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1) 110 self.disp(_("File request sent to {jid}".format(jid=self.args.jid)), 1)
105 try: 111 try:
106 await self.set_progress_id(data["progress"]) 112 await self.set_progress_id(data["progress"])
107 except KeyError: 113 except KeyError:
108 # TODO: if 'xmlui' key is present, manage xmlui message display 114 # TODO: if 'xmlui' key is present, manage xmlui message display
109 self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True) 115 self.disp(_("Can't send file to {jid}".format(jid=self.args.jid)), error=True)
110 self.host.quit(2) 116 self.host.quit(2)
111 117
118
112 async def start(self): 119 async def start(self):
120 file_ = None
113 for file_ in self.args.files: 121 for file_ in self.args.files:
114 if not os.path.exists(file_): 122 if not os.path.exists(file_):
115 self.disp( 123 self.disp(
116 _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True 124 _("file {file_} doesn't exist!").format(file_=repr(file_)), error=True
117 ) 125 )
146 for file_ in self.args.files: 154 for file_ in self.args.files:
147 self.disp(_("Adding {}").format(file_), 1) 155 self.disp(_("Adding {}").format(file_), 1)
148 bz2.add(file_) 156 bz2.add(file_)
149 bz2.close() 157 bz2.close()
150 self.disp(_("Done !"), 1) 158 self.disp(_("Done !"), 1)
151 159 self.args.files = [buf.name]
160 if not self.args.name:
161 self.args.name = archive_name
162
163 for file_ in self.args.files:
164 file_path = Path(file_)
165 if self.args.webrtc:
166 root_logger = logging.getLogger()
167 # we don't want any formatting for messages from webrtc
168 for handler in root_logger.handlers:
169 handler.setFormatter(None)
170 if self.verbosity == 0:
171 root_logger.setLevel(logging.ERROR)
172 if self.verbosity >= 1:
173 root_logger.setLevel(logging.WARNING)
174 if self.verbosity >= 2:
175 root_logger.setLevel(logging.DEBUG)
176 from libervia.frontends.tools.webrtc_file import WebRTCFileSender
177 aio.install_glib_asyncio_iteration()
178 file_sender = WebRTCFileSender(
179 self.host.bridge,
180 self.profile,
181 on_call_start_cb=self.got_id,
182 end_call_cb=self.host.a_quit
183 )
184 await file_sender.send_file_webrtc(
185 file_path,
186 self.args.jid,
187 self.args.name
188 )
189 else:
152 try: 190 try:
153 send_data = await self.host.bridge.file_send( 191 send_data_raw = await self.host.bridge.file_send(
154 self.args.jid, 192 self.args.jid,
155 buf.name, 193 str(file_path.absolute()),
156 self.args.name or archive_name,
157 "",
158 data_format.serialise(extra),
159 self.profile,
160 )
161 except Exception as e:
162 self.disp(f"can't send file: {e}", error=True)
163 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
164 else:
165 await self.got_id(send_data, file_)
166 else:
167 for file_ in self.args.files:
168 path = os.path.abspath(file_)
169 try:
170 send_data = await self.host.bridge.file_send(
171 self.args.jid,
172 path,
173 self.args.name, 194 self.args.name,
174 "", 195 "",
175 data_format.serialise(extra), 196 data_format.serialise(extra),
176 self.profile, 197 self.profile,
177 ) 198 )
178 except Exception as e: 199 except Exception as e:
179 self.disp(f"can't send file {file_!r}: {e}", error=True) 200 self.disp(f"can't send file {file_!r}: {e}", error=True)
180 self.host.quit(C.EXIT_BRIDGE_ERRBACK) 201 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
181 else: 202 else:
182 await self.got_id(send_data, file_) 203 send_data = data_format.deserialise(send_data_raw)
204 await self.got_id(send_data)
183 205
184 206
185 class Request(base.CommandBase): 207 class Request(base.CommandBase):
186 def __init__(self, host): 208 def __init__(self, host):
187 super(Request, self).__init__( 209 super(Request, self).__init__(
299 "receive", 321 "receive",
300 use_progress=True, 322 use_progress=True,
301 use_verbose=True, 323 use_verbose=True,
302 help=_("wait for a file to be sent by a contact"), 324 help=_("wait for a file to be sent by a contact"),
303 ) 325 )
304 self._overwrite_refused = False # True when one overwrite as already been refused 326 self._overwrite_refused = False # True when one overwrite has already been refused
305 self.action_callbacks = { 327 self.action_callbacks = {
306 C.META_TYPE_CONFIRM: self.on_confirm_action, 328 C.META_TYPE_CONFIRM: self.on_confirm_action,
307 C.META_TYPE_FILE: self.on_file_action, 329 C.META_TYPE_FILE: self.on_file_action,
308 C.META_TYPE_OVERWRITE: self.on_overwrite_action, 330 C.META_TYPE_OVERWRITE: self.on_overwrite_action,
309 C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action, 331 C.META_TYPE_NOT_IN_ROSTER_LEAK: self.on_not_in_roster_action,
324 self.parser.add_argument( 346 self.parser.add_argument(
325 "-f", 347 "-f",
326 "--force", 348 "--force",
327 action="store_true", 349 action="store_true",
328 help=_( 350 help=_(
329 "force overwritting of existing files (/!\\ name is choosed by sender)" 351 "force overwriting of existing files (/!\\ name is choosed by sender)"
330 ), 352 ),
331 ) 353 )
332 self.parser.add_argument( 354 self.parser.add_argument(
333 "--path", 355 "--path",
334 default=".", 356 default=".",
352 self.disp(_("hash can't be verified"), 1) 374 self.disp(_("hash can't be verified"), 1)
353 375
354 async def on_progress_error(self, e): 376 async def on_progress_error(self, e):
355 self.disp(_("Error while receiving file: {e}").format(e=e), error=True) 377 self.disp(_("Error while receiving file: {e}").format(e=e), error=True)
356 378
379 async def _on_webrtc_close(self) -> None:
380 if not self.args.multiple:
381 await self.host.a_quit()
382
383 async def on_webrtc_file(
384 self,
385 from_jid: jid.JID,
386 session_id: str,
387 file_data: dict
388 ) -> None:
389 from libervia.frontends.tools.webrtc_file import WebRTCFileReceiver
390 aio.install_glib_asyncio_iteration()
391 root_logger = logging.getLogger()
392 # we don't want any formatting for messages from webrtc
393 for handler in root_logger.handlers:
394 handler.setFormatter(None)
395 if self.verbosity == 0:
396 root_logger.setLevel(logging.ERROR)
397 if self.verbosity >= 1:
398 root_logger.setLevel(logging.WARNING)
399 if self.verbosity >= 2:
400 root_logger.setLevel(logging.DEBUG)
401
402 dest_path = Path(self.path)
403
404 if dest_path.is_dir():
405 filename = file_data.get("name", "unammed_file")
406 dest_path /= filename
407 if dest_path.exists() and not self.args.force:
408 self.host.disp(
409 "Destination file already exists",
410 error=True
411 )
412 aio.run_from_thread(
413 self.host.a_quit, C.EXIT_ERROR, loop=self.host.loop.loop
414 )
415 return
416
417 file_receiver = WebRTCFileReceiver(
418 self.host.bridge,
419 self.profile,
420 on_close_cb=self._on_webrtc_close
421 )
422
423 await file_receiver.receive_file_webrtc(
424 from_jid,
425 session_id,
426 dest_path,
427 file_data
428 )
429
430
357 def get_xmlui_id(self, action_data): 431 def get_xmlui_id(self, action_data):
358 # FIXME: we temporarily use ElementTree, but a real XMLUI managing module 432 # FIXME: we temporarily use ElementTree, but a real XMLUI managing module
359 # should be available in the futur 433 # should be available in the futur
360 # TODO: XMLUI module 434 # TODO: XMLUI module
361 try: 435 try:
374 if xmlui_id is None: 448 if xmlui_id is None:
375 return self.host.quit_from_signal(1) 449 return self.host.quit_from_signal(1)
376 if action_data.get("subtype") != C.META_TYPE_FILE: 450 if action_data.get("subtype") != C.META_TYPE_FILE:
377 self.disp(_("Ignoring confirm dialog unrelated to file."), 1) 451 self.disp(_("Ignoring confirm dialog unrelated to file."), 1)
378 return 452 return
379 453 try:
380 # we always accept preflight confirmation dialog, as for now a second dialog is 454 from_jid = jid.JID(action_data["from_jid"])
381 # always sent 455 except KeyError:
382 # FIXME: real confirmation should be done here, and second dialog should not be 456 self.disp(_("Ignoring action without from_jid data"), 1)
383 # sent from backend 457 return
384 xmlui_data = {"answer": C.BOOL_TRUE} 458
459 # We accept if no JID is specified (meaning "accept all") or if the sender is
460 # explicitly specified.
461 answer = not self.bare_jids or from_jid.bare in self.bare_jids
462 xmlui_data = {"answer": C.bool_const(answer)}
385 await self.host.bridge.action_launch( 463 await self.host.bridge.action_launch(
386 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile 464 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
387 ) 465 )
388 466
389 async def on_file_action(self, action_data, action_id, security_limit, profile): 467 async def on_file_action(self, action_data, action_id, security_limit, profile):
399 progress_id = action_data["progress_id"] 477 progress_id = action_data["progress_id"]
400 except KeyError: 478 except KeyError:
401 self.disp(_("ignoring action without progress id"), 1) 479 self.disp(_("ignoring action without progress id"), 1)
402 return 480 return
403 481
404 if not self.bare_jids or from_jid.bare in self.bare_jids: 482 webrtc = action_data.get("webrtc", False)
483 file_accepted = action_data.get("file_accepted", False)
484
485 if file_accepted or not self.bare_jids or from_jid.bare in self.bare_jids:
405 if self._overwrite_refused: 486 if self._overwrite_refused:
406 self.disp(_("File refused because overwrite is needed"), error=True) 487 self.disp(_("File refused because overwrite is needed"), error=True)
407 await self.host.bridge.action_launch( 488 await self.host.bridge.action_launch(
408 xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}), 489 xmlui_id, data_format.serialise({"cancelled": C.BOOL_TRUE}),
409 profile_key=profile 490 profile_key=profile
410 ) 491 )
411 return self.host.quit_from_signal(2) 492 return self.host.quit_from_signal(2)
412 await self.set_progress_id(progress_id) 493 await self.set_progress_id(progress_id)
413 xmlui_data = {"path": self.path} 494 if webrtc:
495 xmlui_data = {"answer": C.BOOL_TRUE}
496 file_data = action_data.get("file_data") or {}
497 try:
498 session_id = action_data["session_id"]
499 except KeyError:
500 self.disp(_("ignoring action without session id"), 1)
501 return
502 await self.on_webrtc_file(
503 from_jid,
504 session_id,
505 file_data
506 )
507
508 else:
509 xmlui_data = {"path": self.path}
414 await self.host.bridge.action_launch( 510 await self.host.bridge.action_launch(
415 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile 511 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
416 ) 512 )
417 513
418 async def on_overwrite_action(self, action_data, action_id, security_limit, profile): 514 async def on_overwrite_action(self, action_data, action_id, security_limit, profile):
436 xmlui_data = {"answer": C.bool_const(self.args.force)} 532 xmlui_data = {"answer": C.bool_const(self.args.force)}
437 await self.host.bridge.action_launch( 533 await self.host.bridge.action_launch(
438 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile 534 xmlui_id, data_format.serialise(xmlui_data), profile_key=profile
439 ) 535 )
440 536
441 async def on_not_in_roster_action(self, action_data, action_id, security_limit, profile): 537 async def on_not_in_roster_action(
538 self, action_data, action_id, security_limit, profile
539 ):
442 xmlui_id = self.get_xmlui_id(action_data) 540 xmlui_id = self.get_xmlui_id(action_data)
443 if xmlui_id is None: 541 if xmlui_id is None:
444 return self.host.quit_from_signal(1) 542 return self.host.quit_from_signal(1)
445 try: 543 try:
446 from_jid = jid.JID(action_data["from_jid"]) 544 from_jid = jid.JID(action_data["from_jid"])
616 self.disp(url) 714 self.disp(url)
617 715
618 async def on_progress_error(self, error_msg): 716 async def on_progress_error(self, error_msg):
619 self.disp(_("Error while uploading file: {}").format(error_msg), error=True) 717 self.disp(_("Error while uploading file: {}").format(error_msg), error=True)
620 718
621 async def got_id(self, data, file_): 719 async def got_id(self, data):
622 """Called when a progress id has been received 720 """Called when a progress id has been received"""
623
624 @param pid(unicode): progress id
625 @param file_(str): file path
626 """
627 try: 721 try:
628 await self.set_progress_id(data["progress"]) 722 await self.set_progress_id(data["progress"])
629 except KeyError: 723 except KeyError:
630 if "xmlui" in data: 724 if "xmlui" in data:
631 ui = xmlui_manager.create(self.host, data["xmlui"]) 725 ui = xmlui_manager.create(self.host, data["xmlui"])
667 ) 761 )
668 except Exception as e: 762 except Exception as e:
669 self.disp(f"error while trying to upload a file: {e}", error=True) 763 self.disp(f"error while trying to upload a file: {e}", error=True)
670 self.host.quit(C.EXIT_BRIDGE_ERRBACK) 764 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
671 else: 765 else:
672 await self.got_id(upload_data, file_) 766 await self.got_id(upload_data)
673 767
674 768
675 class ShareAffiliationsSet(base.CommandBase): 769 class ShareAffiliationsSet(base.CommandBase):
676 def __init__(self, host): 770 def __init__(self, host):
677 super(ShareAffiliationsSet, self).__init__( 771 super(ShareAffiliationsSet, self).__init__(