304 lines
11 KiB
C#
304 lines
11 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using RelayClient.Crypto;
|
|
using RelayShared.Rtc;
|
|
using RelayShared.Services;
|
|
|
|
namespace RelayClient.Services;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class RtcBridgeService
|
|
{
|
|
/// <summary>The currently-signed-in username. Stamped onto outgoing RTC signals.</summary>
|
|
private readonly string _username;
|
|
|
|
/// <summary>The shared WebSocket to RelayServer. Outbound RTC signals ride on this.</summary>
|
|
private readonly RelaySocketClient _socket;
|
|
|
|
/// <summary>The MAUI HybridWebView that hosts the JS WebRTC engine. We push JS calls into it.</summary>
|
|
private readonly HybridWebView _hybridWebView;
|
|
|
|
/// <summary>Lazy view into MainPage._currentChannelId so we always have the current voice channel.</summary>
|
|
private readonly Func<string?> _getCurrentChannelId;
|
|
|
|
/// <summary>Diagnostic logger that surfaces messages back to the WebView UI. Used for status/error reporting.</summary>
|
|
private readonly Action<string> _sendRawToWebView;
|
|
|
|
/// <summary>Captures collaborators. MainPage constructs this once and never replaces it.</summary>
|
|
public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView,
|
|
Func<string?> getCurrentChannelId, Action<string> sendRawToWebView)
|
|
{
|
|
_username = username;
|
|
_socket = socket;
|
|
_hybridWebView = hybridWebView;
|
|
_getCurrentChannelId = getCurrentChannelId;
|
|
_sendRawToWebView = sendRawToWebView;
|
|
}
|
|
|
|
/// <summary>Sends RtcJoin for the currently-selected channel. Server-side, this triggers the Speak permission check and presence registration.</summary>
|
|
public Task JoinRtcChannel()
|
|
{
|
|
var channelId = _getCurrentChannelId();
|
|
|
|
if (string.IsNullOrWhiteSpace(channelId))
|
|
return Task.CompletedTask;
|
|
|
|
_socket.SendRtcJoinChannel(channelId);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Sends RtcLeave for the currently-selected channel. Clears server-side voice presence so peers stop seeing us.</summary>
|
|
public void LeaveRtcChannel()
|
|
{
|
|
var channelId = _getCurrentChannelId();
|
|
|
|
if (string.IsNullOrWhiteSpace(channelId))
|
|
return;
|
|
|
|
_socket.SendRtcLeaveChannel(channelId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<RtcSignalMessage>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>JS bridge: returns the current voice-channel roster as JSON. Hits ServerAPI's REST endpoint, not the WebSocket.</summary>
|
|
public async Task<string> GetRtcParticipants()
|
|
{
|
|
var channelId = _getCurrentChannelId();
|
|
|
|
if (string.IsNullOrWhiteSpace(channelId))
|
|
return "[]";
|
|
|
|
var participants = await ServerAPI.GetRtcParticipantsAsync(channelId);
|
|
return JsonSerializer.Serialize(participants ?? []);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<RtcSignalMessage>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<RtcSignalMessage>))]
|
|
[JsonSerializable(typeof(RtcSignalMessage))]
|
|
[JsonSerializable(typeof(IceCandidate))]
|
|
[JsonSerializable(typeof(List<IceCandidate>))]
|
|
[JsonSerializable(typeof(string))]
|
|
internal partial class RtcJsType : JsonSerializerContext
|
|
{
|
|
} |