# HG changeset patch # User Goffi # Date 1712401577 -7200 # Node ID 0a4433a343a3cca4847fde1b37470a2fd0220ea9 # Parent 197350e8bf3bd544cdd0e430f26de9d3fddd597d browser (calls): implement WebRTC file sharing: - Send file through WebRTC when the new `file` button is used during a call. - Show a confirmation dialog and download file sent by WebRTC. rel 442 diff -r 197350e8bf3b -r 0a4433a343a3 libervia/web/pages/_browser/dialog.py --- a/libervia/web/pages/_browser/dialog.py Tue Mar 05 16:40:25 2024 +0100 +++ b/libervia/web/pages/_browser/dialog.py Sat Apr 06 13:06:17 2024 +0200 @@ -9,6 +9,11 @@ class CancelError(Exception): """Dialog is cancelled""" + def __init__(self, reason: str = "", text: str = "") -> None: + self.reason = reason + self.text = text + super().__init__(text) + class Confirm: @@ -34,7 +39,7 @@ def _default_cancel_cb(self, evt, notif_elt): notif_elt.remove() - def cancel(self): + def cancel(self, reason: str = "", text: str = ""): """Cancel the dialog, without calling any callback will raise a CancelError @@ -45,7 +50,7 @@ self._notif_elt.remove() self._notif_elt = None if self._reject_cb is not None: - self._reject_cb(CancelError) + self._reject_cb(CancelError(reason, text)) else: log.warning("no reject callback set") self.reset() diff -r 197350e8bf3b -r 0a4433a343a3 libervia/web/pages/calls/_browser/__init__.py --- a/libervia/web/pages/calls/_browser/__init__.py Tue Mar 05 16:40:25 2024 +0100 +++ b/libervia/web/pages/calls/_browser/__init__.py Sat Apr 06 13:06:17 2024 +0200 @@ -4,6 +4,7 @@ from browser import aio, console as log, document, window from cache import cache import dialog +from javascript import JSObject from jid import JID from jid_search import JidSearch import loading @@ -42,9 +43,11 @@ on_video_devices=self.on_video_devices, on_reset_cb=self.on_reset_cb, ) + # mapping of file sending + self.files_webrtc: list[dict] = [] self.mode = "search" self._status = None - self._callee = None + self._callee: JID|None = None self.contacts_elt = document["contacts"] self.search_container_elt = document["search_container"] self.call_container_elt = document["call_container"] @@ -82,6 +85,8 @@ else: self.share_desktop_col_elt.classList.add("is-hidden") document["switch_camera_btn"].bind("click", self.on_switch_camera) + document["send_file_btn"].bind("click", self.on_send_file) + document["send_file_input"].bind("change", self._on_send_input_change) # search self.search_elt = document["search"] @@ -185,13 +190,128 @@ @param profile: Profile associated with the action """ action_data = json.loads(action_data_s) - if action_data.get("type") != "call": + if action_data.get("type") == "confirm" and action_data.get("subtype") == "file": + aio.run(self.on_file_preflight(action_data, action_id)) + elif action_data.get("type") == "file": + aio.run(self.on_file_proposal(action_data, action_id)) + elif action_data.get("type") != "call": return - aio.run(self.on_action_new(action_data, action_id)) + else: + aio.run(self.on_action_new(action_data, action_id)) + + def get_human_size(self, size: int|float) -> str: + """Return size in human-friendly size using SI units""" + units = ["o","Kio","Mio","Gio"] + for idx, unit in enumerate(units): + if size < 1024.0 or idx == len(units)-1: + return f"{size:.2f}{unit}" + size /= 1024.0 + raise Exception("Internal Error: this line should never be reached.") + + async def request_file_permission(self, action_data: dict) -> bool: + """Request permission to download a file.""" + peer_jid = JID(action_data["from_jid"]).bare + await cache.fill_identities([peer_jid]) + identity = cache.identities[peer_jid] + peer_name = identity["nicknames"][0] + + file_data = action_data.get("file_data", {}) + + file_name = file_data.get('name') + file_size = file_data.get('size') + + if file_name: + file_name_msg = 'wants to send you the file "{file_name}"'.format( + file_name=file_name + ) + else: + file_name_msg = 'wants to send you an unnamed file' + + if file_size is not None: + file_size_msg = "which has a size of {file_size_human}".format( + file_size_human=self.get_human_size(file_size) + ) + else: + file_size_msg = "which has an unknown size" + + file_description = file_data.get('desc') + if file_description: + description_msg = " Description: {}.".format(file_description) + else: + description_msg = "" + + file_data = action_data.get("file_data", {}) + + file_accept_dlg = dialog.Confirm( + "{peer_name} ({peer_jid}) {file_name_msg} {file_size_msg}.{description_msg} Do you " + "accept?".format( + peer_name=peer_name, + peer_jid=peer_jid, + file_name_msg=file_name_msg, + file_size_msg=file_size_msg, + description_msg=description_msg + ), + ok_label="Download", + cancel_label="Reject" + ) + return await file_accept_dlg.ashow() + + async def on_file_preflight(self, action_data: dict, action_id: str) -> None: + """Handle a file preflight (proposal made to all devices).""" + # FIXME: temporarily done in call page, will be moved to notifications handler to + # make it work anywhere. + accepted = await self.request_file_permission(action_data) + + await bridge.action_launch( + action_id, json.dumps({"answer": str(accepted).lower()}) + ) + + async def on_file_proposal(self, action_data: dict, action_id: str) -> None: + """Handle a file proposal. + + This is a proposal made specifically to this device, a opposed to + ``on_file_preflight``. File may already have been accepted during preflight. + """ + # FIXME: as for on_file_preflight, this will be moved to notification handler. + if not action_data.get("webrtc", False): + peer_jid = JID(action_data["from_jid"]).bare + # We try to do a not-too-technical warning about webrtc not being supported. + dialog.notification.show( + f"A file sending from {peer_jid} can't be accepted because it is not " + "compatible with web browser direct transfer (WebRTC).", + level="warning", + ) + # We don't explicitly refuse the file proposal, because it may be accepted and + # supported by other frontends. + # TODO: Check if any other frontend is connected for this profile, and refuse + # the file if none is. + return + if action_data.get("file_accepted", False): + # File proposal has already been accepted in preflight. + accepted = True + else: + accepted = await self.request_file_permission(action_data) + + if accepted: + sid = action_data["session_id"] + webrtc = WebRTC( + file_only=True, + extra_data={"file_data": action_data.get("file_data", {})} + ) + webrtc.sid = sid + self.files_webrtc.append({ + "webrtc": webrtc, + }) + + await bridge.action_launch( + action_id, json.dumps({"answer": str(accepted).lower()}) + ) async def on_action_new(self, action_data: dict, action_id: str) -> None: peer_jid = JID(action_data["from_jid"]).bare - log.info(f"{peer_jid} wants to start a call ({action_data['sub_type']})") + call_type = action_data["sub_type"] + call_emoji = "📹" if call_type == VIDEO else "📞" + log.info(f"{peer_jid} wants to start a call ({call_type}).") if self.sid is not None: log.warning( f"already in a call ({self.sid}), can't receive a new call from " @@ -210,14 +330,30 @@ # and ask user if we take the call try: self.incoming_call_dialog_elt = dialog.Confirm( - f"{peer_name} is calling you.", ok_label="Answer", cancel_label="Reject" + f"{peer_name} is calling you ({call_emoji}{call_type}).", ok_label="Answer", cancel_label="Reject" ) accepted = await self.incoming_call_dialog_elt.ashow() - except dialog.CancelError: + except dialog.CancelError as e: log.info("Call has been cancelled") self.incoming_call_dialog_elt = None self.sid = None - dialog.notification.show(f"{peer_name} has cancelled the call", level="info") + match e.reason: + case "busy": + dialog.notification.show( + f"{peer_name} can't answer your call", + level="info", + ) + case "taken_by_other_device": + device = e.text + dialog.notification.show( + f"The call has been taken on another device ({device}).", + level="info", + ) + case _: + dialog.notification.show( + f"{peer_name} has cancelled the call", + level="info" + ) return self.incoming_call_dialog_elt = None @@ -230,6 +366,7 @@ log.debug(f"Call SID: {sid}") # Answer the call + self.call_mode = call_type self.set_avatar(peer_jid) self.status = "connecting" self.switch_mode("call") @@ -281,11 +418,18 @@ sdp: Session Description Protocol data @param profile: Profile associated """ - if self.sid != session_id: - log.debug( - f"Call ignored due to different session ID ({self.sid=} {session_id=})" - ) - return + if self.sid == session_id: + webrtc = self.webrtc + else: + for file_webrtc in self.files_webrtc: + webrtc = file_webrtc["webrtc"] + if webrtc.sid == session_id: + break + else: + log.debug( + f"Call ignored due to different session ID ({self.sid=} {session_id=})" + ) + return try: role = setup_data["role"] sdp = setup_data["sdp"] @@ -295,9 +439,9 @@ ) return if role == "initiator": - await self.webrtc.accept_call(session_id, sdp, profile) + await webrtc.accept_call(session_id, sdp, profile) elif role == "responder": - await self.webrtc.answer_call(session_id, sdp, profile) + await webrtc.answer_call(session_id, sdp, profile) else: dialog.notification.show( f"Invalid role received during setup: {setup_data}", level="error" @@ -358,20 +502,15 @@ # if there is any ringing, we stop it self.audio_player_elt.pause() self.audio_player_elt.currentTime = 0 + reason = data.get("reason", "") + text = data.get("text", "") if self.incoming_call_dialog_elt is not None: - self.incoming_call_dialog_elt.cancel() + self.incoming_call_dialog_elt.cancel(reason, text) self.incoming_call_dialog_elt = None self.switch_mode("search") - if data.get("reason") == "busy": - assert self._callee is not None - peer_name = cache.identities[self._callee]["nicknames"][0] - dialog.notification.show( - f"{peer_name} can't answer your call", - level="info", - ) await self.webrtc.end_call() @@ -540,6 +679,24 @@ def on_switch_camera(self, __) -> None: aio.run(self.webrtc.switch_camera()) + def on_send_file(self, __) -> None: + document["send_file_input"].click() + + def _on_send_input_change(self, evt) -> None: + aio.run(self.on_send_input_change(evt)) + + async def on_send_input_change(self, evt) -> None: + assert self._callee is not None + files = evt.currentTarget.files + for file in files: + webrtc = WebRTC(file_only=True) + self.files_webrtc.append({ + "file": file, + "webrtc": webrtc + }) + await webrtc.send_file(self._callee, file) + + def _on_entity_click(self, item: dict) -> None: aio.run(self.on_entity_click(item)) diff -r 197350e8bf3b -r 0a4433a343a3 libervia/web/pages/calls/_browser/webrtc.py --- a/libervia/web/pages/calls/_browser/webrtc.py Tue Mar 05 16:40:25 2024 +0100 +++ b/libervia/web/pages/calls/_browser/webrtc.py Sat Apr 06 13:06:17 2024 +0200 @@ -2,9 +2,8 @@ import re from bridge import AsyncBridge as Bridge -from browser import aio, console as log, document, timer, window +from browser import aio, console as log, document, window import dialog -import errors from javascript import JSObject import jid @@ -13,7 +12,85 @@ bridge = Bridge() +class FileSender: + CHUNK_SIZE = 64 * 1024 + + def __init__(self, session_id, file, data_channel): + self.session_id = session_id + self.file = file + self.data_channel = data_channel + data_channel.bind("open", self._on_open) + self.offset = 0 + + def _on_open(self, __): + log.info(f"Data channel open, starting to send {self.file.name}.") + self.send_file() + + def _on_reader_load(self, event): + self.data_channel.send(event.target.result) + self.offset += self.CHUNK_SIZE + self.send_file() + + def send_file(self): + if self.offset < self.file.size: + chunk = self.file.slice(self.offset, self.offset + self.CHUNK_SIZE) + reader = window.FileReader.new() + reader.onload = self._on_reader_load + reader.readAsArrayBuffer(chunk) + else: + log.info(f"file {self.file.name} sent.") + self.data_channel.close() + if self.session_id is not None: + aio.run(bridge.call_end(self.session_id, "")) + + +class FileReceiver: + + def __init__(self, session_id: str, data_channel, extra_data: dict) -> None: + """Initializes the file receiver with a data channel. + + @param data_channel: The RTCDataChannel through which file data is received. + """ + self.session_id = session_id + self.data_channel = data_channel + self.received_chunks = [] + self.file_data = extra_data.get("file_data", {}) + data_channel.bind("message", self._on_message) + data_channel.bind("close", self._on_close) + log.debug("File receiver created.") + + def _on_message(self, event) -> None: + """Handles incoming message events from the data channel. + + @param event: The event containing the data chunk. + """ + self.received_chunks.append(event.data) + + def _on_close(self, __) -> None: + """Handles the data channel's close event. + + Assembles the received chunks into a Blob and triggers a file download. + """ + # The file is complete, we assemble the chunks in a blob + blob = window.Blob.new(self.received_chunks) + url = window.URL.createObjectURL(blob) + + # and create the element to download the file. + a = document.createElement("a") + a.href = url + a.download = self.file_data.get("name", "received_file") + document.body.appendChild(a) + a.click() + + # We now clean up. + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + log.info("File received.") + aio.run(bridge.call_end(self.session_id, "")) + + class WebRTC: + def __init__( self, screen_sharing_cb=None, @@ -22,7 +99,21 @@ on_connection_lost_cb=None, on_video_devices=None, on_reset_cb=None, + file_only: bool = False, + extra_data: dict|None = None ): + """Initialise WebRTC instance. + + @param screen_sharing_cb: callable function for screen sharing event + @param on_connection_established_cb: callable function for connection established event + @param on_reconnect_cb: called when a reconnection is triggered. + @param on_connection_lost_cb: called when the connection is lost. + @param on_video_devices: called when new video devices are set. + @param on_reset_cb: called on instance reset. + @param file_only: indicates a file transfer only session. + @param extra_data: optional dictionary containing additional data. + Notably used for file transfer, where ``file_data`` key is used. + """ # reset self.on_reset_cb = on_reset_cb self.reset_instance() @@ -42,8 +133,16 @@ self.has_multiple_cameras = False self.current_camera = None - # Initially populate the video devices list - aio.run(self._populate_video_devices()) + self.file_only = file_only + if not file_only: + # Initially populate the video devices list + aio.run(self._populate_video_devices()) + + # video elements + self.local_video_elt = document["local_video"] + self.remote_video_elt = document["remote_video"] + else: + self.file_sender = None # muting self.is_audio_muted = None @@ -53,9 +152,10 @@ self._is_sharing_screen = False self.screen_sharing_cb = screen_sharing_cb - # video elements - self.local_video_elt = document["local_video"] - self.remote_video_elt = document["remote_video"] + # extra + if extra_data is None: + extra_data = {} + self.extra_data = extra_data @property def is_sharing_screen(self) -> bool: @@ -73,7 +173,6 @@ self._peer_connection = None self._media_types = None self._media_types_inv = None - self._callee = None self.ufrag = None self.pwd = None self.sid = None @@ -82,6 +181,7 @@ self.remote_candidates_buffer = { "audio": {"candidates": []}, "video": {"candidates": []}, + "application": {"candidates": []}, } self.local_candidates_buffer = {} self.media_candidates = {} @@ -318,7 +418,7 @@ async def _create_peer_connection( self, - ): + ) -> JSObject: """Creates peer connection""" if self._peer_connection is not None: raise Exception("create_peer_connection can't be called twice!") @@ -354,6 +454,7 @@ self._peer_connection = peer_connection window.pc = self._peer_connection + return peer_connection async def _get_user_media(self, audio: bool = True, video: bool = True) -> None: """ @@ -585,20 +686,21 @@ @param candidates: Dictionary containing new ICE candidates """ log.debug(f"new peer candidates received: {candidates}") - # FIXME: workaround for https://github.com/brython-dev/brython/issues/2227, the - # following test raise a JS exception + try: + # FIXME: javascript.NULL must be used here, once we move to Brython 3.12.3+ remoteDescription_is_none = self._peer_connection.remoteDescription is None except Exception as e: + # FIXME: should be fine in Brython 3.12.3+ log.debug("Workaround for Brython bug activated.") remoteDescription_is_none = True if ( self._peer_connection is None - # or self._peer_connection.remoteDescription is None + # or self._peer_connection.remoteDescription is NULL or remoteDescription_is_none ): - for media_type in ("audio", "video"): + for media_type in ("audio", "video", "application"): media_candidates = candidates.get(media_type) if media_candidates: buffer = self.remote_candidates_buffer[media_type] @@ -610,12 +712,13 @@ try: sdp_mline_index = self.get_sdp_mline_index(media_type) except Exception as e: - log.warning(e) + log.warning(f"Can't get sdp_mline_index: {e}") continue - ice_candidate = window.RTCIceCandidate.new( - {"candidate": candidate_sdp, "sdpMLineIndex": sdp_mline_index} - ) - await self._peer_connection.addIceCandidate(ice_candidate) + else: + ice_candidate = window.RTCIceCandidate.new( + {"candidate": candidate_sdp, "sdpMLineIndex": sdp_mline_index} + ) + await self._peer_connection.addIceCandidate(ice_candidate) def on_track(self, event): """New track has been received from peer @@ -635,6 +738,14 @@ log.debug(f"on_negotiation_needed {event=}") # TODO + def _on_data_channel(self, event) -> None: + """Handles the data channel event from the peer connection. + + @param event: The event associated with the opening of a data channel. + """ + data_channel = event.channel + self.file_receiver = FileReceiver(self.sid, data_channel, self.extra_data) + async def answer_call(self, sid: str, offer_sdp: str, profile: str): """We respond to the call""" log.debug("answering call") @@ -647,7 +758,10 @@ ) await self.on_ice_candidates_new(self.remote_candidates_buffer) self.remote_candidates_buffer.clear() - await self._get_user_media() + if self.file_only: + self._peer_connection.bind("datachannel", self._on_data_channel) + else: + await self._get_user_media() # Gather local ICE candidates local_ice_data = await self._gather_ice_candidates(False) @@ -655,23 +769,13 @@ await bridge.call_answer_sdp(sid, self._peer_connection.localDescription.sdp) - async def make_call( - self, callee_jid: jid.JID, audio: bool = True, video: bool = True - ) -> None: - """Start a WebRTC call - - @param audio: True if an audio flux is required - @param video: True if a video flux is required - """ - await self._create_peer_connection() - await self._get_user_media(audio, video) + async def _get_call_data(self) -> dict: + """Start a WebRTC call""" await self._gather_ice_candidates(True) - call_data = {"sdp": self._peer_connection.localDescription.sdp} - log.info(f"calling {callee_jid!r}") - self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data)) - log.debug(f"Call SID: {self.sid}") + return {"sdp": self._peer_connection.localDescription.sdp} + async def _send_buffered_local_candidates(self) -> None: if self.local_candidates_buffer: log.debug( f"sending buffered local ICE candidates: {self.local_candidates_buffer}" @@ -684,16 +788,68 @@ "pwd": self.pwd, "candidates": candidates } - aio.run( - bridge.ice_candidates_add( - self.sid, - json.dumps( - ice_data - ), - ) + await bridge.ice_candidates_add( + self.sid, + json.dumps( + ice_data + ), ) self.local_candidates_buffer.clear() + async def make_call( + self, callee_jid: jid.JID, audio: bool = True, video: bool = True + ) -> None: + """ + @param audio: True if an audio flux is required + @param video: True if a video flux is required + """ + await self._create_peer_connection() + await self._get_user_media(audio, video) + call_data = await self._get_call_data() + log.info(f"calling {callee_jid!r}") + self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data)) + log.debug(f"Call SID: {self.sid}") + await self._send_buffered_local_candidates() + + def _on_opened_data_channel(self, event): + log.info("Datachannel has been opened.") + + async def send_file(self, callee_jid: jid.JID, file: JSObject) -> None: + assert self.file_only + peer_connection = await self._create_peer_connection() + data_channel = peer_connection.createDataChannel("file") + call_data = await self._get_call_data() + log.info(f"sending file to {callee_jid!r}") + file_meta = { + "size": file.size + } + if file.type: + file_meta["media_type"] = file.type + + try: + file_data = json.loads(await bridge.file_jingle_send( + str(callee_jid), + "", + file.name, + "", + json.dumps({ + "webrtc": True, + "call_data": call_data, + **file_meta + }) + )) + except Exception as e: + dialog.notification.show( + f"Can't send file: {e}", level="error" + ) + return + + self.sid = file_data["session_id"] + + log.debug(f"File Transfer SID: {self.sid}") + await self._send_buffered_local_candidates() + self.file_sender = FileSender(self.sid, file, data_channel) + async def end_call(self) -> None: """Stop streaming and clean instance""" if self._peer_connection is None: diff -r 197350e8bf3b -r 0a4433a343a3 libervia/web/server/restricted_bridge.py --- a/libervia/web/server/restricted_bridge.py Tue Mar 05 16:40:25 2024 +0100 +++ b/libervia/web/server/restricted_bridge.py Sat Apr 06 13:06:17 2024 +0200 @@ -16,11 +16,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from libervia.backend.core import exceptions +from libervia.backend.core.log import getLogger from libervia.backend.tools.common import data_format -from libervia.backend.core import exceptions + from libervia.web.server.constants import Const as C +log = getLogger(__name__) + + class RestrictedBridge: """bridge with limited access, which can be used in browser @@ -94,6 +99,32 @@ return await self.host.bridge_call( "external_disco_get", entity, profile) + async def file_jingle_send( + self, + peer_jid: str, + filepath: str, + name: str, + file_desc: str, + extra_s: str, + profile: str + ) -> str: + self.no_service_profile(profile) + if filepath: + # The file sending must be done P2P from the browser directly (the file is + # from end-user machine), and its data must be set in "extra". + # "filepath" must NOT be used in this case, as it would link a local file + # (i.e. from the backend machine), which is an obvious security issue. + log.warning( + f'"filepath" user by {profile!r} in file_jingle_send, this is not ' + "allowed, hack attempt?" + ) + raise exceptions.PermissionError( + "Using a filepath is not allowed." + ) + return await self.host.bridge_call( + "file_jingle_send", peer_jid, "", name, file_desc, extra_s, profile + ) + async def history_get( self, from_jid: str,