using System.Text.Json; using System.Text.Json.Serialization; using RelayClient.Crypto; using RelayShared.Rtc; using RelayShared.Services; namespace RelayClient.Services; /// /// The bridge between the C# WebSocket pipe and the JavaScript WebRTC engine /// running inside the HybridWebView (which is shown when a Voice channel is open). /// /// Outbound (JS → C# → server): the WebView JS calls into C# via SendRtcSignal(json). /// We deserialise to RtcSignalMessage, encrypt with the server's public key, wrap in /// SocketRtcSignalMessage, and send through the WebSocket. /// /// Inbound (server → C# → JS): the WebSocket fires EncryptedRtcSignalReceived. MainPage /// hands it to HandleIncomingRtcSignalAsync, which decrypts with the user's private key /// and calls back into JS via hybridWebView.InvokeJavaScriptAsync("testIndex", …). /// /// JoinRtcChannel / LeaveRtcChannel just send WsAction control messages; presence tracking /// happens server-side in RtcChannelPresenceService. /// public sealed class RtcBridgeService { /// The currently-signed-in username. Stamped onto outgoing RTC signals. private readonly string _username; /// The shared WebSocket to RelayServer. Outbound RTC signals ride on this. private readonly RelaySocketClient _socket; /// The MAUI HybridWebView that hosts the JS WebRTC engine. We push JS calls into it. private readonly HybridWebView _hybridWebView; /// Lazy view into MainPage._currentChannelId so we always have the current voice channel. private readonly Func _getCurrentChannelId; /// Diagnostic logger that surfaces messages back to the WebView UI. Used for status/error reporting. private readonly Action _sendRawToWebView; /// Captures collaborators. MainPage constructs this once and never replaces it. public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView, Func getCurrentChannelId, Action sendRawToWebView) { _username = username; _socket = socket; _hybridWebView = hybridWebView; _getCurrentChannelId = getCurrentChannelId; _sendRawToWebView = sendRawToWebView; } /// Sends RtcJoin for the currently-selected channel. Server-side, this triggers the Speak permission check and presence registration. public Task JoinRtcChannel() { var channelId = _getCurrentChannelId(); if (string.IsNullOrWhiteSpace(channelId)) return Task.CompletedTask; _socket.SendRtcJoinChannel(channelId); return Task.CompletedTask; } /// Sends RtcLeave for the currently-selected channel. Clears server-side voice presence so peers stop seeing us. public void LeaveRtcChannel() { var channelId = _getCurrentChannelId(); if (string.IsNullOrWhiteSpace(channelId)) return; _socket.SendRtcLeaveChannel(channelId); } /// /// Called from JavaScript (via the HybridWebView bridge) when the WebRTC engine wants to /// send an SDP offer/answer or ICE candidate to other peers. Parses the JSON, fills in /// missing ChannelId/From, encrypts with the server's public key, ships as /// SocketRtcSignalMessage. The server then forwards it (re-encrypted per-recipient) to /// every other session in the same voice channel. /// public void SendRtcSignal(string json) { if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey)) { _sendRawToWebView("SendRtcSignal failed: server public key not loaded."); return; } RtcSignalMessage? rtcSignal; try { rtcSignal = JsonSerializer.Deserialize(json); } catch (Exception ex) { _sendRawToWebView("SendRtcSignal failed to parse RTC signal: " + ex.Message); return; } if (rtcSignal is null) return; rtcSignal.ChannelId ??= _getCurrentChannelId(); rtcSignal.From ??= _username; // _sendRawToWebView($"RTC_SIGNAL file: {JsonSerializer.Serialize(rtcSignal)}"); if (string.IsNullOrWhiteSpace(rtcSignal.ChannelId)) { _sendRawToWebView("SendRtcSignal failed: missing channel id."); return; } var outgoingJson = JsonSerializer.Serialize(rtcSignal); try { var encrypted = E2EeHelper.EncryptForRecipient(outgoingJson, _socket.ServerPublicKey); var payload = new SocketRtcSignalMessage { Type = SignalType.EncryptedSignal, SenderUsername = _username, ChannelId = rtcSignal.ChannelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey }; _socket.SendJson(payload); _sendRawToWebView($"SendRtcSignal sent: {rtcSignal.Type} -> {rtcSignal.To}"); } catch (Exception ex) { _sendRawToWebView("SendRtcSignal failed: " + ex.Message); } } /// JS bridge: returns the current voice-channel roster as JSON. Hits ServerAPI's REST endpoint, not the WebSocket. public async Task GetRtcParticipants() { var channelId = _getCurrentChannelId(); if (string.IsNullOrWhiteSpace(channelId)) return "[]"; var participants = await ServerAPI.GetRtcParticipantsAsync(channelId); return JsonSerializer.Serialize(participants ?? []); } /// /// MainPage hands incoming SocketRtcSignalMessage frames here. Filters out our own /// frames, validates the channel scope, decrypts with the user's private key, parses to /// RtcSignalMessage, then pushes into the JS RTC engine via SendRtcSignalToJsAsync. /// public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload) { // _sendRawToWebView("HandleIncomingRtcSignal called"); var currentChannelId = _getCurrentChannelId(); if (payload.ChannelId != currentChannelId) { _sendRawToWebView("Channel id does not match"); return; } if (payload.SenderUsername == _username) { _sendRawToWebView("Received own message"); return; } string decryptedJson; try { var privateKey = KeyStorage.LoadPrivateKey(_username); decryptedJson = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, privateKey ); } catch (Exception ex) { _sendRawToWebView("RTC decrypt failed: " + ex.Message); return; } RtcSignalMessage? rtcSignal; try { rtcSignal = JsonSerializer.Deserialize(decryptedJson); // _sendRawToWebView($"Received Encrypted Signal: [{rtcSignal.From}]: {rtcSignal.Offer}"); } catch (Exception ex) { _sendRawToWebView("RTC signal parse failed: " + ex.Message); return; } if (rtcSignal is null) { _sendRawToWebView("rtcSignal is null"); return; } if (!string.IsNullOrWhiteSpace(rtcSignal.To) && !string.Equals(rtcSignal.To, _username, StringComparison.OrdinalIgnoreCase)) { _sendRawToWebView($"Ignoring RTC signal meant for {rtcSignal.To}"); return; } // _sendRawToWebView("Received encrypted RTC signal: " + decryptedJson); await SendRtcSignalToJsAsync(rtcSignal); } /// /// Pushes the current username and channelId into JS globals (window.setUsername, window.setChannelId). /// Called whenever the user switches voice channels OR the JS engine reports rtc_page_ready. /// public Task PushRtcContextToJsAsync() { MainThread.BeginInvokeOnMainThread(async () => { var usernameJson = JsonSerializer.Serialize(_username); var channelIdJson = JsonSerializer.Serialize(_getCurrentChannelId()); await _hybridWebView.EvaluateJavaScriptAsync($"window.setUsername({usernameJson})"); await _hybridWebView.EvaluateJavaScriptAsync($"window.setChannelId({channelIdJson})"); }); return Task.CompletedTask; } /// /// Final hop: hands a decrypted RtcSignalMessage off to the JS engine via /// hybridWebView.InvokeJavaScriptAsync("testIndex", …). SDP strings have their newlines /// escaped as "(rn)" because the JSON marshalling otherwise breaks them. /// private Task SendRtcSignalToJsAsync(RtcSignalMessage data) { if (data.Type == "rtc_offer" || data.Type == "rtc_answer") { data.Sdp = data.Sdp.Replace("\r\n", "(rn)"); } MainThread.BeginInvokeOnMainThread(async () => { try { // await _hybridWebView.InvokeJavaScriptAsync("testIndex", [JsonSerializer.Serialize(data)], [RtcJsType.Default.String]); await _hybridWebView.InvokeJavaScriptAsync("testIndex", [data], [RtcJsType.Default.RtcSignalMessage]); #region OldDebugger // var jsArg = JsonSerializer.Serialize(data); // // await _hybridWebView.EvaluateJavaScriptAsync($@" // try {{ // window.HybridWebView.SendRawMessage('C# eval entered'); // // if (!window.RelaySocket) {{ // window.HybridWebView.SendRawMessage('window.RelaySocket missing'); // }} else if (typeof window.RelaySocket.receiveRtcSignal !== 'function') {{ // window.HybridWebView.SendRawMessage('RelaySocket.receiveRtcSignal missing'); // }} else {{ // window.HybridWebView.SendRawMessage('Calling RelaySocket.receiveRtcSignal'); // window.RelaySocket.receiveRtcSignal({jsArg}); // }} // }} catch (err) {{ // window.HybridWebView.SendRawMessage('RTC JS dispatch failed: ' + err); // }} // "); #endregion } catch (Exception ex) { _sendRawToWebView("SendRtcSignalToJsAsync failed: " + ex.Message); } }); return Task.CompletedTask; } } [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(RtcDescription))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(RtcSignalMessage))] [JsonSerializable(typeof(IceCandidate))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(string))] internal partial class RtcJsType : JsonSerializerContext { }