diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index 5cef8f1..a229d64 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -1,23 +1,23 @@ -using RelayClient.Crypto; -using WebSocketSharp; -using System.Text.Json; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; +using RelayClient.Crypto; +using RelayClient.Services; using RelayShared.Rtc; +using RelayShared.Services; namespace RelayClient; - public partial class MainPage : ContentPage { private readonly string _username; - private readonly WebSocket _wsc; - private string? _serverPublicKey; + private readonly RelaySocketClient _socket; + private readonly RtcBridgeService _rtc; + private string? _currentChannelId; private string? _currentChannelName; private readonly Dictionary> _messagesByChannel = new(); private readonly List _channels = []; - + public MainPage(string username) { InitializeComponent(); @@ -32,19 +32,31 @@ public partial class MainPage : ContentPage KeyStorage.SavePublicKey(_username, keys.publicKey); } - _wsc = new WebSocket("ws://localhost:1337/"); - - _wsc.OnMessage += WscOnMessage; - _wsc.Connect(); - - var publicKey = KeyStorage.LoadPublicKey(_username); - _wsc.Send($"REGISTER_KEY|{_username}|{publicKey}"); - _wsc.Send("GET_SERVER_KEY"); - _wsc.Send("GET_CHANNELS"); - - hybridWebView.SetInvokeJavaScriptTarget(this); ServerAPI.setupClient(); + _socket = new RelaySocketClient(_username); + _rtc = new RtcBridgeService( + _username, + _socket, + hybridWebView, + () => _currentChannelId, + SafeSendRawToWebView + ); + + hybridWebView.SetInvokeJavaScriptTarget(_rtc); + + _socket.Log += Console.WriteLine; + _socket.ChannelListReceived += HandleChannelList; + _socket.EncryptedChatReceived += HandleEncryptedChat; + _socket.EncryptedRtcSignalReceived += payload => + { + MainThread.BeginInvokeOnMainThread(async () => + { + await _rtc.HandleIncomingRtcSignalAsync(payload); + }); + }; + + _socket.Connect(); } private void SendButton_OnClicked(object? sender, EventArgs e) @@ -64,19 +76,19 @@ public partial class MainPage : ContentPage if (string.IsNullOrWhiteSpace(text)) return; - if (string.IsNullOrWhiteSpace(_serverPublicKey)) + if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey)) { Console.WriteLine("Server public key not loaded yet."); return; } - + if (string.IsNullOrWhiteSpace(_currentChannelId)) { Console.WriteLine("No channel selected yet."); return; } - var encrypted = E2EeHelper.EncryptForRecipient(text, _serverPublicKey); + var encrypted = E2EeHelper.EncryptForRecipient(text, _socket.ServerPublicKey); var payload = new SocketEncryptedMessage { @@ -89,8 +101,7 @@ public partial class MainPage : ContentPage EncryptedKey = encrypted.EncryptedKey }; - var json = JsonSerializer.Serialize(payload); - _wsc.Send(json); + _socket.SendJson(payload); Console.WriteLine($"[{_username}] sent encrypted message."); @@ -98,248 +109,83 @@ public partial class MainPage : ContentPage MessageEntry.Focus(); } - private void WscOnMessage(object? sender, MessageEventArgs e) + private void HandleChannelList(SocketChannelList channelList) { - if (e.Data.StartsWith("SERVER:REGISTERED_KEY:")) + _channels.Clear(); + _channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt)); + + var defaultChannel = _channels + .Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase)) + .OrderBy(c => c.CreatedAt) + .FirstOrDefault() + ?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault(); + + if (defaultChannel is null) return; + + _currentChannelId = defaultChannel.ChannelId; + _currentChannelName = defaultChannel.Name; + + MainThread.BeginInvokeOnMainThread(async () => { - Console.WriteLine(e.Data); + ChannelLabel.Text = $"#{_currentChannelName}"; + RenderChannelList(); + await _rtc.PushRtcContextToJsAsync(); + }); + + _socket.SendRaw($"GET_HISTORY|{_username}|{_currentChannelId}"); + } + + private void HandleEncryptedChat(SocketEncryptedMessage payload) { + if (payload.RecipientUsername != _username) return; - } - - // SafeSendRawToWebView($"[{_username}] RAW WS DATA: {e.Data}"); - - Console.WriteLine($"[{_username}] RAW WS DATA: {e.Data}"); + + string decryptedText; try { - using var doc = JsonDocument.Parse(e.Data); - var root = doc.RootElement; + var privateKey = KeyStorage.LoadPrivateKey(_username); - if (!root.TryGetProperty("Type", out var typeElement)) - return; - - var type = (SignalType) typeElement.GetInt32(); - - if (type == SignalType.ChannelList) - { - var channelList = JsonSerializer.Deserialize(e.Data); - if (channelList is null) - return; - - _channels.Clear(); - _channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt)); - - var defaultChannel = _channels - .Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase)) - .OrderBy(c => c.CreatedAt) - .FirstOrDefault() - ?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault(); - - if (defaultChannel is not null) - { - _currentChannelId = defaultChannel.ChannelId; - _currentChannelName = defaultChannel.Name; - - MainThread.BeginInvokeOnMainThread(async () => - { - ChannelLabel.Text = $"#{_currentChannelName}"; - RenderChannelList(); - await PushRtcContextToJsAsync(); - }); - - _wsc.Send($"GET_HISTORY|{_username}|{_currentChannelId}"); - } - - return; - } - - if (type == SignalType.ServerPublicKey) - { - var serverKeyMessage = JsonSerializer.Deserialize(e.Data); - if (serverKeyMessage is not null) - { - _serverPublicKey = serverKeyMessage.PublicKey; - Console.WriteLine($"[{_username}] loaded server public key."); - } - - return; - } - - if (type == SignalType.EncryptedSignal) - { - var payload = JsonSerializer.Deserialize(e.Data); - if (payload is null) - return; - - if (payload.ChannelId != _currentChannelId) - return; - - if (payload.SenderUsername == _username) - return; - - var privateKey = KeyStorage.LoadPrivateKey(_username); - - var decryptedJson = E2EeHelper.DecryptForRecipient( - new EncryptedPayload - { - CipherText = payload.CipherText, - Nonce = payload.Nonce, - Tag = payload.Tag, - EncryptedKey = payload.EncryptedKey - }, - privateKey - ); - - var rtcSignal = JsonSerializer.Deserialize(decryptedJson); - - if (rtcSignal is null) - return; - - if (!string.IsNullOrWhiteSpace(rtcSignal.To) && - !string.Equals(rtcSignal.To, _username, StringComparison.OrdinalIgnoreCase)) - { - SafeSendRawToWebView($"Ignoring RTC signal meant for {rtcSignal.To}"); - return; - } - - SafeSendRawToWebView("Received encrypted RTC signal: " + decryptedJson); - - MainThread.BeginInvokeOnMainThread(async () => - { - await SendRtcSignalToJsAsync(decryptedJson); - }); - - return; - } - - if (type is SignalType.OfferUpdated or SignalType.AnswerUpdated or SignalType.CandidateAdded or SignalType.CallLeft) - { - var rtcNotification = JsonSerializer.Deserialize(e.Data); - if (rtcNotification is null) - return; - - var notificationType = rtcNotification.Type; - var notificationChannelId = rtcNotification.ChannelId ?? string.Empty; - - if (notificationChannelId != _currentChannelId) - return; - - // SafeSendRawToWebView("RTC notification received: " + notificationType + " for " + notificationChannelId); - - MainThread.BeginInvokeOnMainThread(async () => - { - switch (notificationType) - { - case SignalType.OfferUpdated: - { - if (rtcNotification.Username == _username) - break; - - var offer = await GetRtcOffer(); - await SendRtcSignalToJsAsync(offer); - break; - } - case SignalType.AnswerUpdated: - { - var answer = await ServerAPI.GetAnswerForChannelAsync(_currentChannelId); - if (answer is not null) - { - await AnswerCallback(answer); - } - break; - } - case SignalType.CandidateAdded: - { - if (rtcNotification.Username == _username) - break; - - try - { - IceCandidate? iceCandidate = JsonSerializer.Deserialize(rtcNotification.Direction); - - if (iceCandidate is null) - break; - - IceCandidateCallback(iceCandidate); - } - catch (Exception ex) - { - SafeSendRawToWebView($"Candidate rejected: {ex.Message}"); - } - - break; - } - case SignalType.CallLeft: - { - SafeSendRawToWebView("RTC call left notification received."); - RtcLeaveCallback(); - break; - } - } - }); - - return; - } - - if (type != SignalType.EncryptedChat) - return; - - var pyload = JsonSerializer.Deserialize(e.Data); - if (pyload is null) - return; - - if (pyload.RecipientUsername != _username) - return; - - Console.WriteLine($"[{_username}] received encrypted payload for {pyload.RecipientUsername}"); - - var privKey = KeyStorage.LoadPrivateKey(_username); - - var decryptedText = E2EeHelper.DecryptForRecipient( + decryptedText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { - CipherText = pyload.CipherText, - Nonce = pyload.Nonce, - Tag = pyload.Tag, - EncryptedKey = pyload.EncryptedKey + CipherText = payload.CipherText, + Nonce = payload.Nonce, + Tag = payload.Tag, + EncryptedKey = payload.EncryptedKey }, - privKey - ); - - Console.WriteLine($"[{_username}] decrypted message from {pyload.SenderUsername}: {decryptedText}"); - - var message = new ChatMessage - { - SenderUsername = pyload.SenderUsername, - Text = decryptedText, - Timestamp = DateTime.Now - }; - - if (!_messagesByChannel.ContainsKey(pyload.ChannelId)) - { - _messagesByChannel[pyload.ChannelId] = []; - } - - _messagesByChannel[pyload.ChannelId].Add(message); - - if (pyload.ChannelId == _currentChannelId) - { - MainThread.BeginInvokeOnMainThread(() => - { - RenderSingleMessage(message); - }); - } + privateKey + ); } catch (Exception ex) { - Console.WriteLine($"[{_username}] failed to process websocket message: {ex.Message}"); + Console.WriteLine($"[{_username}] failed to decrypt chat: {ex.Message}"); + return; + } + + var message = new ChatMessage + { + SenderUsername = payload.SenderUsername, + Text = decryptedText, + Timestamp = DateTime.Now + }; + + if (!_messagesByChannel.ContainsKey(payload.ChannelId)) + _messagesByChannel[payload.ChannelId] = []; + + _messagesByChannel[payload.ChannelId].Add(message); + + if (payload.ChannelId == _currentChannelId) + { + MainThread.BeginInvokeOnMainThread(() => + { + RenderSingleMessage(message); + }); } } protected override void OnDisappearing() { - _wsc.OnMessage -= WscOnMessage; - _wsc.Close(); + _socket.Disconnect(); base.OnDisappearing(); } @@ -360,14 +206,17 @@ public partial class MainPage : ContentPage { _currentChannelId = channel.ChannelId; _currentChannelName = channel.Name; - + MainThread.BeginInvokeOnMainThread(async () => { - await PushRtcContextToJsAsync(); + await _rtc.PushRtcContextToJsAsync(); + if (channel.Type == ChannelType.Voice) { - SwapView(); - // JoinRtcChannel(); //TODO: Join voice calls when clicking channel rather than a separate button + if (!RtcView.IsVisible) + SwapView(); + + _ = _rtc.JoinRtcChannel(); } }); @@ -375,9 +224,7 @@ public partial class MainPage : ContentPage RenderCurrentChannelMessages(); if (!_messagesByChannel.ContainsKey(channel.ChannelId)) - { - _wsc.Send($"GET_HISTORY|{_username}|{channel.ChannelId}"); - } + _socket.SendRaw($"GET_HISTORY|{_username}|{channel.ChannelId}"); }; SidebarList.Children.Add(button); @@ -402,7 +249,7 @@ public partial class MainPage : ContentPage private async void RenderSingleMessage(ChatMessage message) { - bool isOwnMessage = message.SenderUsername == _username; + var isOwnMessage = message.SenderUsername == _username; var bubble = new Border { @@ -437,7 +284,6 @@ public partial class MainPage : ContentPage MessagesScrollView.IsVisible = true; RtcView.IsVisible = false; ViewSwapped.Text = "Swap to Web View"; - } else { @@ -446,267 +292,23 @@ public partial class MainPage : ContentPage ViewSwapped.Text = "Swap to Message View"; } } + private void SwapView_OnClicked(object? sender, EventArgs e) { SwapView(); } - #region RTC Functions - public async Task JoinRtcChannel() - { - if (string.IsNullOrWhiteSpace(_currentChannelId)) - return; //false; - - _wsc.Send($"RTC_JOIN_CHANNEL|{_username}|{_currentChannelId}"); - - // SafeSendRawToWebView($"Attempting to join RTC Channel {_currentChannelName} | {_currentChannelId} "); - - //bool active = await ServerAPI.GetIsChannelActiveAsync(_currentChannelId); - - //SafeSendRawToWebView($"Rtc Channel {_currentChannelName} | {_currentChannelId} is active: {active}"); - - return; //active; - } - - public void LeaveRtcChannel() - { - if (string.IsNullOrWhiteSpace(_currentChannelId)) - return; - - _wsc.Send($"RTC_LEAVE_CHANNEL|{_username}|{_currentChannelId}"); - } - - public async void WriteRtcOffer(string json) - { - try - { - RtcDescription? description = JsonSerializer.Deserialize(json); - DBOffer offer = new DBOffer - { - ChannelId = _currentChannelId, - Username = _username, - SessionDescription = description - }; - var response = await ServerAPI.PostOfferAsync(offer); - SafeSendRawToWebView(response.ToString()); - } - catch (Exception ex) - { - SafeSendRawToWebView($"WriteRtcOffer failed: {ex.Message}"); - } - - } - public async Task GetRtcOffer() - { - RtcDescription? offer = await ServerAPI.GetOffersForChannelAsync(_currentChannelId); - return JsonSerializer.Serialize(offer); - } - - public async void WriteRtcAnswer(string json) - { - // SafeSendRawToWebView("WriteRtcAnswer entered with: " + json); - - try - { - RtcDescription? description = JsonSerializer.Deserialize(json); - DBOffer answer = new DBOffer - { - ChannelId = _currentChannelId, - Username = _username, - SessionDescription = description - }; - await ServerAPI.PostAnswerAsync(answer); - SafeSendRawToWebView("WriteRtcAnswer posted successfully"); - } - catch (Exception ex) - { - SafeSendRawToWebView("WriteRtcAnswer failed: " + ex.Message); - } - } - - public async void WriteIceCandidate(string json) - { - try - { - IceCandidate? candidate = JsonSerializer.Deserialize(json); - DBIceCandidate DBCandidate = new DBIceCandidate - { - ChannelId = _currentChannelId, - Username = _username, - Candidate = candidate - }; - if (candidate == null) return; - await ServerAPI.PostIceCandidateAsync(DBCandidate); - } - catch (Exception ex) - { - SafeSendRawToWebView("WriteIceCandidate failed: " + ex.Message); - } - } - - public async void IceCandidateCallback(IceCandidate candidate) - { - try - { - await hybridWebView.InvokeJavaScriptAsync("IceCandidateAdded", [candidate], [HybridJSType.Default.IceCandidate]); - } - catch (Exception ex) - { - SafeSendRawToWebView("WriteIceCandidate failed: " + ex.Message); - } - } - public async Task AnswerCallback(RtcDescription answer) - { - answer.sdp = answer.sdp.Replace("\r\n", "(rn)"); - try - { - await hybridWebView.InvokeJavaScriptAsync("AnswerCallbackJS", [answer], [HybridJSType.Default.RtcDescription]); - } - catch (Exception ex) - { - SafeSendRawToWebView("AnswerCallback failed: " + ex.Message); - } - } - - public async void RtcLeaveCallback() - { - try - { - await hybridWebView.InvokeJavaScriptAsync("RtcLeaveCall", [], []); - } - catch (Exception ex) - { - SafeSendRawToWebView("RtcLeaveCallback failed: " + ex.Message); - } - } - - private Task SendRtcSignalToJsAsync(string rawJson) - { - MainThread.BeginInvokeOnMainThread(async () => - { - try - { - SafeSendRawToWebView("Dispatching RTC signal to JS"); - - var jsArg = JsonSerializer.Serialize(rawJson); - - await hybridWebView.EvaluateJavaScriptAsync($@" - window.HybridWebView.SendRawMessage('JS dispatch wrapper hit'); - const fn = window.handleRtcSignal || window.dispatchRtcSignal; - if (!fn) {{ - window.HybridWebView.SendRawMessage('No RTC signal handler found on window'); - }} else {{ - window.HybridWebView.SendRawMessage('Calling RTC signal handler'); - fn({jsArg}); - }} - "); - - SafeSendRawToWebView("RTC signal dispatched to JS"); - } - catch (Exception ex) - { - SafeSendRawToWebView("SendRtcSignalToJsAsync failed: " + ex.Message); - } - }); - - return Task.CompletedTask; - } //Remove? - - private async Task PushRtcContextToJsAsync() - { - var usernameJson = JsonSerializer.Serialize(_username); - var channelIdJson = JsonSerializer.Serialize(_currentChannelId); - - await hybridWebView.EvaluateJavaScriptAsync($"window.setUsername({usernameJson})"); - await hybridWebView.EvaluateJavaScriptAsync($"window.setChannelId({channelIdJson})"); - - Console.WriteLine($"[{_username}] pushed RTC context into HybridWebView."); - } //Remove? - - public void SendRtcSignal(string json) - { - SafeSendRawToWebView("SendRtcSignal entered: " + json); - - if (string.IsNullOrWhiteSpace(_serverPublicKey)) - { - SafeSendRawToWebView("SendRtcSignal failed: server public key not loaded."); - return; - } - - RtcSignalMessage? rtcSignal; - - try - { - rtcSignal = JsonSerializer.Deserialize(json); - } - catch (Exception ex) - { - SafeSendRawToWebView("SendRtcSignal failed to parse RTC signal: " + ex.Message); - return; - } - - if (rtcSignal is null) - { - SafeSendRawToWebView("SendRtcSignal failed: rtcSignal was null."); - return; - } - - if (string.IsNullOrWhiteSpace(rtcSignal.ChannelId)) - { - SafeSendRawToWebView("SendRtcSignal failed: channelId was empty."); - return; - } - - try - { - var encrypted = E2EeHelper.EncryptForRecipient(json, _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 - }; - - var socketJson = JsonSerializer.Serialize(payload); - _wsc.Send(socketJson); - - SafeSendRawToWebView($"SendRtcSignal sent: {rtcSignal.Type} -> {rtcSignal.ChannelId}"); - Console.WriteLine($"[{_username}] sent RTC signal: {rtcSignal.Type} -> {rtcSignal.ChannelId}"); - } - catch (Exception ex) - { - SafeSendRawToWebView("SendRtcSignal websocket/encrypt failed: " + ex.Message); - } - } //Remove? - - public async Task GetRtcParticipants() - { - var participants = await ServerAPI.GetRtcParticipantsAsync(_currentChannelId); - return JsonSerializer.Serialize(participants); - } - - #endregion - private void OnSendMessageButtonClicked(object sender, EventArgs e) - { - SafeSendRawToWebView($"Hello from C#!"); - } - private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e) { if (e.Message == "rtc_page_ready") { - await PushRtcContextToJsAsync(); + await _rtc.PushRtcContextToJsAsync(); return; } SafeSendRawToWebView($"JS RAW -> C#: {e.Message}"); } - + private void SafeSendRawToWebView(string message) { MainThread.BeginInvokeOnMainThread(() => @@ -725,9 +327,9 @@ public partial class MainPage : ContentPage public class ChannelButton : Button { public ChannelType Type { get; set; } - public string Group { get; set; } + public string Group { get; set; } = string.Empty; } - + [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(RtcDescription))] [JsonSerializable(typeof(List))] @@ -736,8 +338,5 @@ public partial class MainPage : ContentPage [JsonSerializable(typeof(string))] internal partial class HybridJSType : JsonSerializerContext { - // This type's attributes specify JSON serialization info to preserve type structure - // for trimmed builds. } - } \ No newline at end of file diff --git a/RelayClient/MauiProgram.cs b/RelayClient/MauiProgram.cs index c6d626a..fe2e144 100644 --- a/RelayClient/MauiProgram.cs +++ b/RelayClient/MauiProgram.cs @@ -5,11 +5,8 @@ namespace RelayClient; public static class MauiProgram { - // public static event Action? MessageSent; public static MauiApp CreateMauiApp() { - //wsc.OnMessage += (sender, e) => OnWebSocketRecieved(sender, e); - //wsc.Connect(); var builder = MauiApp.CreateBuilder(); builder.UseMauiApp().ConfigureFonts(fonts => { @@ -18,8 +15,6 @@ public static class MauiProgram fonts.AddFont("AnonymousPro-Italic.ttf", "AnonymousProItalic"); fonts.AddFont("AnonymousPro-Regular.ttf", "AnonymousProRegular"); }); - - #if DEBUG builder.Services.AddHybridWebViewDeveloperTools(); @@ -28,19 +23,4 @@ public static class MauiProgram return builder.Build(); } - - //public static void OnWebSocketRecieved(object? sender, MessageEventArgs e) - //{ - // Console.WriteLine(sender.ToString()); - // - // ChatSimulator.Send(e.Data.Split(":")[0], e.Data.Split(":")[1]); - // // var message = new ChatMessage - // // { - // // SenderUsername = e.Data.Split(":")[0], - // // Text = e.Data.Split(":")[1], - // // Timestamp = DateTime.Now - // // }; - // // - // // MessageSent?.Invoke(message); - //} } \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/index.css b/RelayClient/Resources/Raw/wwwroot/index.css index 005dc59..a6fff29 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.css +++ b/RelayClient/Resources/Raw/wwwroot/index.css @@ -86,4 +86,29 @@ textarea::-webkit-scrollbar-thumb { border: 1px solid #332940; border-radius: 10px; padding: 12px; +} + +.remote-media-container { + display: flex; + flex-direction: row; + gap: 16px; + align-items: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + padding: 8px 0; +} + +.remote-media-tile, +.remote-tile { + flex: 0 0 auto; + width: 320px; +} + +.remote-media-tile video, +.remote-tile video { + width: 320px; + height: 240px; + background: #111; + border-radius: 8px; + object-fit: cover; } \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/index.html b/RelayClient/Resources/Raw/wwwroot/index.html index e6237f7..cd1b890 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.html +++ b/RelayClient/Resources/Raw/wwwroot/index.html @@ -8,6 +8,9 @@ + + + @@ -16,8 +19,8 @@
- - + +
diff --git a/RelayClient/Resources/Raw/wwwroot/index.js b/RelayClient/Resources/Raw/wwwroot/index.js index 4374da7..2d822bb 100644 --- a/RelayClient/Resources/Raw/wwwroot/index.js +++ b/RelayClient/Resources/Raw/wwwroot/index.js @@ -1,506 +1,41 @@ -let peerConnection = null; -let peerConnections = {}; -let remoteStreams = {}; -let localStream = null; -let currentUsername = null; +let currentUsername = null; let currentChannelId = null; -let availableCameras = []; -let availableMics = []; -let candidateQueue = []; -const configuration = { - iceServers:[ - { - urls:[ - 'stun:stun1.l.google.com:19302', - 'stun:stun2.l.google.com:19302', - ], - }, - ], - iceCandidatePoolSize: 10, -} -window.setUsername = function(name) { +const configuration = { + iceServers: [ + { + urls: [ + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302" + ] + } + ], + iceCandidatePoolSize: 10 +}; + +window.setUsername = function (name) { currentUsername = name; LogMessage("Username set to: " + currentUsername); }; -window.setChannelId = function(channelId) { + +window.setChannelId = function (channelId) { currentChannelId = channelId; LogMessage("Channel set to: " + currentChannelId); }; function LogMessage(msg) { const messageLog = document.getElementById("messageLog"); - messageLog.value += '\r\n' + msg; + + if (!messageLog) { + console.log(msg); + return; + } + + messageLog.value += "\r\n" + msg; messageLog.scrollTop = messageLog.scrollHeight; } -function hasVideoTrack() { - return !!localStream && localStream.getVideoTracks().length > 0; -} - -function hasAudioTrack() { - return !!localStream && localStream.getAudioTracks().length > 0; -} - -async function ensureLocalMedia(forceReload = false) { - const localMediaStatus = document.getElementById("localMediaStatus"); - const localVideoStatus = document.getElementById("localVideoStatus"); - const localVideo = document.getElementById("localVideo"); - const cameraSelect = document.getElementById("cameraSelect"); - const micSelect = document.getElementById("micSelect"); - - if (localStream && !forceReload) { - return; - } - - if (localStream) { - localStream.getTracks().forEach(track => track.stop()); - localStream = null; - } - - let selectedCameraId = cameraSelect ? cameraSelect.value : ""; - let selectedMicId = micSelect ? micSelect.value : ""; - - const videoConstraint = selectedCameraId - ? { deviceId: { exact: selectedCameraId } } - : false; - - const audioConstraint = selectedMicId - ? { deviceId: { exact: selectedMicId } } - : true; - - try { - localStream = await navigator.mediaDevices.getUserMedia({ - video: videoConstraint, - audio: audioConstraint - }); - - LogMessage("Local media initialized"); - } catch (err) { - LogMessage("selected media failed: " + err); - - try { - localStream = await navigator.mediaDevices.getUserMedia({ - video: false, - audio: audioConstraint - }); - - LogMessage("Local media initialized with audio only fallback"); - } catch (audioErr) { - LogMessage("audio-only failed: " + audioErr); - - if (localMediaStatus) localMediaStatus.textContent = "Local media failed"; - if (localVideoStatus) localVideoStatus.textContent = "Local video: unavailable"; - if (localVideo) localVideo.srcObject = null; - - throw audioErr; - } - } - - const hasVideo = localStream.getVideoTracks().length > 0; - const hasAudio = localStream.getAudioTracks().length > 0; - - localVideo.srcObject = hasVideo ? localStream : null; - - if (localVideoStatus) { - localVideoStatus.textContent = hasVideo - ? "Local video: active" - : "Local video: unavailable"; - } - - if (localMediaStatus) { - localMediaStatus.textContent = `Local media: audio=${hasAudio} video=${hasVideo}`; - } - - if (!hasVideo) { - LogMessage("No camera available, continuing without video"); - } -} - -async function applyLocalStreamToPeerConnections() { - if (!localStream) return; - - const audioTrack = localStream.getAudioTracks()[0] || null; - const videoTrack = localStream.getVideoTracks()[0] || null; - - for (const username of Object.keys(peerConnections)) { - const pc = peerConnections[username]; - const senders = pc.getSenders(); - - const audioSender = senders.find(s => s.track && s.track.kind === "audio"); - const videoSender = senders.find(s => s.track && s.track.kind === "video"); - - if (audioSender) { - await audioSender.replaceTrack(audioTrack); - LogMessage(`Replaced audio track for ${username}`); - } else if (audioTrack) { - pc.addTrack(audioTrack, localStream); - LogMessage(`Added audio track for ${username}`); - } - - if (videoSender) { - await videoSender.replaceTrack(videoTrack); - LogMessage(`Replaced video track for ${username}`); - } else if (videoTrack) { - pc.addTrack(videoTrack, localStream); - LogMessage(`Added video track for ${username}`); - } - } -} - -async function refreshDevicesAndPreview() { - await loadDevices(); - await ensureLocalMedia(true); - - if (Object.keys(peerConnections).length > 0) { - await applyLocalStreamToPeerConnections(); - } -} - -//TODO: Only join call if no active call in progress for client -//TODO: Enable proper leave call functions -//TODO: Leave call and join new call if client clicks a 2nd voice channel -async function joinChannelCall() { - LogMessage("Current username: " + currentUsername); - LogMessage("Current channel: " + currentChannelId); - - await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); - await ensureLocalMedia(); - - const rawParticipants = await window.HybridWebView.InvokeDotNet("GetRtcParticipants"); - const participants = typeof rawParticipants === "string" - ? JSON.parse(rawParticipants) - : rawParticipants; - - LogMessage("Participants: " + JSON.stringify(participants)); - - const otherUsers = participants.filter(username => username !== currentUsername); - - if (otherUsers.length === 0) { - LogMessage("Joined call as first participant. Waiting for others..."); - return; - } - - for (const username of otherUsers) { - const pc = await ensurePeerConnectionForUser(username); - - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - const payload = { - type: "rtc_offer", - from: currentUsername, - to: username, - channelId: currentChannelId, - sdp: offer.sdp, - isInitiator: true - }; - - await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); - LogMessage(`Sent offer to ${username}`); - } -} - -async function channelCallJoin(activeCall) -{ - // LogMessage("Active call: " + activeCall); - await ensurePeerConnectionForUser(currentUsername); - - if (activeCall) - { - const rawJson = await window.HybridWebView.InvokeDotNet("GetRtcOffer"); - const offer = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; - await peerConnection.setRemoteDescription(offer); - const answer = await peerConnection.createAnswer(); - await peerConnection.setLocalDescription(answer); - // LogMessage("Joining call with media answer: " + JSON.stringify(answer)); - // LogMessage("Calling C# WriteRtcAnswer with: " + JSON.stringify(answer)); - await window.HybridWebView.InvokeDotNet("WriteRtcAnswer", [JSON.stringify(answer)]); - } - else - { - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - await window.HybridWebView.InvokeDotNet("WriteRtcOffer", [JSON.stringify(offer)]); - } -} -async function ensurePeerConnectionForUser(username) { - if (peerConnections[username]) return peerConnections[username]; - - const pc = new RTCPeerConnection(configuration); - peerConnections[username] = pc; - - pc.onicegatheringstatechange = () => { - console.log(`ICE gathering state changed for ${username}: ${pc.iceGatheringState}`); - }; - - pc.onconnectionstatechange = () => { - console.log(`Connection state change for ${username}: ${pc.connectionState}`); - }; - - pc.onsignalingstatechange = () => { - console.log(`Signaling state change for ${username}: ${pc.signalingState}`); - }; - - pc.oniceconnectionstatechange = () => { - console.log(`ICE connection state change for ${username}: ${pc.iceConnectionState}`); - }; - - pc.onicecandidate = async (event) => { - if (!event.candidate) return; - - await window.HybridWebView.InvokeDotNet("WriteIceCandidate", [JSON.stringify(event.candidate)]); - }; - - pc.ontrack = (event) => { - LogMessage(`Remote track received from ${username}`); - - if (!remoteStreams[username]) { - remoteStreams[username] = new MediaStream(); - } - - const stream = remoteStreams[username]; - - event.streams[0].getTracks().forEach(track => { - if (!stream.getTracks().some(t => t.id === track.id)) { - stream.addTrack(track); - } - }); - - const remoteVideo = ensureRemoteTile(username); - if (remoteVideo) { - remoteVideo.srcObject = stream; - } - }; - - if (localStream) { - const existingKinds = pc.getSenders() - .map(sender => sender.track?.kind) - .filter(Boolean); - - for (const track of localStream.getTracks()) { - if (!existingKinds.includes(track.kind)) { - pc.addTrack(track, localStream); - } - } - } - - return pc; -} - -async function RtcLeaveCall() { // TODO: Just a minimal function so it's not empty. - for (const username of Object.keys(peerConnections)) { - peerConnections[username].close(); - removeRemoteTile(username); - } - - peerConnections = {}; - remoteStreams = {}; - candidateQueue = []; - - LogMessage("RTC call cleaned up"); -} - -function removeParticipant(username) { - const pc = peerConnections[username]; - if (pc) { - pc.close(); - delete peerConnections[username]; - } - - delete remoteStreams[username]; - removeRemoteTile(username); - - LogMessage(`Removed participant ${username}`); -} - -async function handleRtcSignal(rawJson) { - try { - const msg = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; - - LogMessage("Received signal: " + msg.type + " from " + msg.from + " in " + msg.channelId); - - if (!msg.from || msg.from === currentUsername) return; - - if (msg.to && msg.to !== currentUsername) { - LogMessage(`Ignoring signal meant for ${msg.to}`); - return; - } - - const pc = await ensurePeerConnectionForUser(msg.from); - - if (msg.type === "rtc_offer") { - await ensureLocalMedia(); - - await pc.setRemoteDescription({ - type: "offer", - sdp: msg.sdp - }); - - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - const payload = { - type: "rtc_answer", - from: currentUsername, - to: msg.from, - channelId: msg.channelId, - sdp: answer.sdp - }; - - await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]); - LogMessage(`Sent answer to ${msg.from}`); - return; - } - - if (msg.type === "rtc_answer") { - await pc.setRemoteDescription({ - type: "answer", - sdp: msg.sdp - }); - - LogMessage(`Remote answer applied for ${msg.from}`); - return; - } - - if (msg.type === "rtc_ice_candidate") { - await pc.addIceCandidate({ - candidate: msg.candidate, - sdpMid: msg.sdpMid, - sdpMLineIndex: msg.sdpMLineIndex - }); - - LogMessage(`Remote ICE candidate applied for ${msg.from}`); - return; - } - - LogMessage("Unhandled signal type: " + msg.type); - } catch (err) { - LogMessage("handleRtcSignal failed: " + err); - } -} - -window.handleRtcSignal = handleRtcSignal; - -async function loadDevices() { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - - availableCameras = devices.filter(d => d.kind === "videoinput"); - availableMics = devices.filter(d => d.kind === "audioinput"); - - const cameraSelect = document.getElementById("cameraSelect"); - const micSelect = document.getElementById("micSelect"); - - if (!cameraSelect || !micSelect) { - LogMessage("Device dropdowns not found."); - return; - } - - cameraSelect.innerHTML = ""; - micSelect.innerHTML = ""; - - const noCameraOption = document.createElement("option"); - noCameraOption.value = ""; - noCameraOption.text = "No camera / audio-only"; - cameraSelect.appendChild(noCameraOption); - - const noMicOption = document.createElement("option"); - noMicOption.value = ""; - noMicOption.text = "Default microphone"; - micSelect.appendChild(noMicOption); - - for (const cam of availableCameras) { - const option = document.createElement("option"); - option.value = cam.deviceId; - option.text = cam.label || `Camera ${cameraSelect.options.length}`; - cameraSelect.appendChild(option); - } - - for (const mic of availableMics) { - const option = document.createElement("option"); - option.value = mic.deviceId; - option.text = mic.label || `Microphone ${micSelect.options.length + 1}`; - micSelect.appendChild(option); - } - - LogMessage(`Loaded devices: ${availableCameras.length} cameras, ${availableMics.length} mics`); - } catch (err) { - LogMessage("loadDevices failed: " + err); - } -} - -function wireDeviceSelectors() { - const cameraSelect = document.getElementById("cameraSelect"); - const micSelect = document.getElementById("micSelect"); - - if (cameraSelect) { - cameraSelect.onchange = async () => { - LogMessage("Camera changed"); - await ensureLocalMedia(true); - await applyLocalStreamToPeerConnections(); - }; - } - - if (micSelect) { - micSelect.onchange = async () => { - LogMessage("Microphone changed"); - await ensureLocalMedia(true); - await applyLocalStreamToPeerConnections(); - }; - } -} - -async function waitForIceGatheringComplete(pc) { - if (pc.iceGatheringState === "complete") return; - - await new Promise(resolve => { - function checkState() { - if (pc.iceGatheringState === "complete") { - pc.removeEventListener("icegatheringstatechange", checkState); - resolve(); - } - } - - pc.addEventListener("icegatheringstatechange", checkState); - }); -} //Remove? - -function ensureRemoteTile(username) { - let tile = document.getElementById(`remote-tile-${username}`); - if (tile) { - return tile.querySelector("video"); - } - - const container = document.getElementById("remoteMediaContainer"); - if (!container) return null; - - tile = document.createElement("div"); - tile.id = `remote-tile-${username}`; - tile.className = "remote-tile"; - - const video = document.createElement("video"); - video.id = `remote-video-${username}`; - video.autoplay = true; - video.playsInline = true; - - const label = document.createElement("div"); - label.className = "remote-label"; - label.textContent = username; - - tile.appendChild(video); - tile.appendChild(label); - container.appendChild(tile); - - return video; -} - -function removeRemoteTile(username) { - const tile = document.getElementById(`remote-tile-${username}`); - if (tile) { - tile.remove(); - } -} +window.LogMessage = LogMessage; window.addEventListener("HybridWebViewMessageReceived", function (e) { LogMessage("Raw message: " + e.detail.message); @@ -508,8 +43,10 @@ window.addEventListener("HybridWebViewMessageReceived", function (e) { window.addEventListener("load", async () => { LogMessage("RTC page loaded"); + window.HybridWebView.SendRawMessage("rtc_page_ready"); - await loadDevices(); - wireDeviceSelectors(); - await ensureLocalMedia(true); + + Media.wireDeviceSelectors(); + await Media.loadDevices(); + await Media.ensureLocalMedia(); }); \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/media.js b/RelayClient/Resources/Raw/wwwroot/media.js new file mode 100644 index 0000000..2629671 --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/media.js @@ -0,0 +1,261 @@ +let localStream = null; +const remoteStreams = {}; + +const Media = { + async loadDevices() { + const devices = await navigator.mediaDevices.enumerateDevices(); + + const cameras = devices.filter(d => d.kind === "videoinput"); + const mics = devices.filter(d => d.kind === "audioinput"); + + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + if (!cameraSelect || !micSelect) return; + + const selectedCamera = cameraSelect.value; + const selectedMic = micSelect.value; + + cameraSelect.innerHTML = ""; + micSelect.innerHTML = ""; + + const noCamera = document.createElement("option"); + noCamera.value = ""; + noCamera.textContent = "No camera / audio only"; + cameraSelect.appendChild(noCamera); + + const defaultMic = document.createElement("option"); + defaultMic.value = ""; + defaultMic.textContent = "Default microphone"; + micSelect.appendChild(defaultMic); + + for (const camera of cameras) { + const option = document.createElement("option"); + option.value = camera.deviceId; + option.textContent = camera.label || `Camera ${cameraSelect.length}`; + cameraSelect.appendChild(option); + } + + for (const mic of mics) { + const option = document.createElement("option"); + option.value = mic.deviceId; + option.textContent = mic.label || `Microphone ${micSelect.length}`; + micSelect.appendChild(option); + } + + cameraSelect.value = [...cameraSelect.options].some(o => o.value === selectedCamera) + ? selectedCamera + : ""; + + micSelect.value = [...micSelect.options].some(o => o.value === selectedMic) + ? selectedMic + : ""; + + LogMessage(`Loaded devices: ${cameras.length} cameras, ${mics.length} mics`); + }, + + async ensureLocalMedia() { + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + if (localStream) { + return localStream; + } + + const audioDeviceId = micSelect?.value || ""; + const videoDeviceId = cameraSelect?.value || ""; + + const constraints = { + audio: audioDeviceId + ? { deviceId: { exact: audioDeviceId } } + : true, + video: videoDeviceId + ? { deviceId: { exact: videoDeviceId } } + : false + }; + + try { + localStream = await navigator.mediaDevices.getUserMedia(constraints); + } catch (err) { + LogMessage("Selected media failed: " + err); + + localStream = await navigator.mediaDevices.getUserMedia({ + audio: audioDeviceId + ? { deviceId: { exact: audioDeviceId } } + : true, + video: false + }); + + LogMessage("No camera available, continuing without video"); + } + + this.attachLocalStream(localStream); + LogMessage("Local media initialized"); + + return localStream; + }, + + attachLocalStream(stream) { + const localVideo = document.getElementById("localVideo"); + const localMediaStatus = document.getElementById("localMediaStatus"); + const localVideoStatus = document.getElementById("localVideoStatus"); + + const audioTracks = stream.getAudioTracks(); + const videoTracks = stream.getVideoTracks(); + + if (localVideo) { + localVideo.srcObject = videoTracks.length > 0 ? stream : null; + } + + if (localMediaStatus) { + localMediaStatus.textContent = + audioTracks.length > 0 + ? "Microphone active" + : "No microphone"; + } + + if (localVideoStatus) { + localVideoStatus.textContent = + videoTracks.length > 0 + ? "Local video active" + : "Local video unavailable"; + } + }, + + async restartLocalMedia() { + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + localStream = null; + } + + await this.ensureLocalMedia(); + + if (window.RelayRtc?.applyLocalStreamToAllPeerConnections) { + await window.RelayRtc.applyLocalStreamToAllPeerConnections(); + } + }, + + async refreshDevicesAndPreview() { + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + localStream = null; + } + + await this.loadDevices(); + await this.ensureLocalMedia(); + + if (window.RelayRtc?.applyLocalStreamToAllPeerConnections) { + await window.RelayRtc.applyLocalStreamToAllPeerConnections(); + } + }, + + async applyLocalStreamToPeerConnection(pc, username) { + const stream = await this.ensureLocalMedia(); + const existingSenders = pc.getSenders(); + + for (const track of stream.getTracks()) { + const existingSender = existingSenders.find(sender => + sender.track && sender.track.kind === track.kind + ); + + if (existingSender) { + await existingSender.replaceTrack(track); + LogMessage(`Replaced local ${track.kind} track for ${username}`); + } else { + pc.addTrack(track, stream); + LogMessage(`Added local ${track.kind} track for ${username}`); + } + } + }, + + async applyLocalStreamToAllPeerConnections() { + if (!window.RelayRtc?.peerConnections) return; + + for (const [username, pc] of Object.entries(window.RelayRtc.peerConnections)) { + await this.applyLocalStreamToPeerConnection(pc, username); + } + }, + + attachRemoteStream(username, stream) { + remoteStreams[username] = stream; + + const tile = this.ensureRemoteTile(username); + const video = tile.querySelector("video"); + const status = tile.querySelector(".remote-media-status"); + + if (video) { + video.srcObject = stream; + } + + const audioTracks = stream.getAudioTracks(); + const videoTracks = stream.getVideoTracks(); + + if (status) { + status.textContent = + `${audioTracks.length > 0 ? "Audio" : "No audio"} / ` + + `${videoTracks.length > 0 ? "Video" : "No video"}`; + } + }, + + ensureRemoteTile(username) { + const container = document.getElementById("remoteMediaContainer"); + if (!container) return null; + + let tile = document.getElementById(`remote-tile-${username}`); + if (tile) return tile; + + tile = document.createElement("div"); + tile.id = `remote-tile-${username}`; + tile.className = "remote-media-tile"; + + const title = document.createElement("div"); + title.className = "remote-media-title"; + title.textContent = username; + + const video = document.createElement("video"); + video.autoplay = true; + video.playsInline = true; + + const status = document.createElement("div"); + status.className = "remote-media-status"; + status.textContent = "Remote media: waiting..."; + + tile.appendChild(title); + tile.appendChild(video); + tile.appendChild(status); + + container.appendChild(tile); + + return tile; + }, + + removeRemoteStream(username) { + delete remoteStreams[username]; + + const tile = document.getElementById(`remote-tile-${username}`); + if (tile) { + tile.remove(); + } + }, + + wireDeviceSelectors() { + const cameraSelect = document.getElementById("cameraSelect"); + const micSelect = document.getElementById("micSelect"); + + if (cameraSelect) { + cameraSelect.addEventListener("change", async () => { + LogMessage("Camera changed"); + await this.restartLocalMedia(); + }); + } + + if (micSelect) { + micSelect.addEventListener("change", async () => { + LogMessage("Microphone changed"); + await this.restartLocalMedia(); + }); + } + } +}; + +window.Media = Media; \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/relaySocket.js b/RelayClient/Resources/Raw/wwwroot/relaySocket.js new file mode 100644 index 0000000..c17f052 --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/relaySocket.js @@ -0,0 +1,46 @@ +const RelaySocket = { + async joinRtcChannel() { + await window.HybridWebView.InvokeDotNet("JoinRtcChannel"); + }, + + async leaveRtcChannel() { + await window.HybridWebView.InvokeDotNet("LeaveRtcChannel"); + }, + + async getRtcParticipants() { + const raw = await window.HybridWebView.InvokeDotNet("GetRtcParticipants"); + + if (!raw) return []; + + return typeof raw === "string" + ? JSON.parse(raw) + : raw; + }, + + async sendRtcSignal(signal) { + if (!signal.channelId) signal.channelId = currentChannelId; + if (!signal.from) signal.from = currentUsername; + + await window.HybridWebView.InvokeDotNet("SendRtcSignal", [ + JSON.stringify(signal) + ]); + }, + + receiveRtcSignal(rawJson) { + LogMessage("RelaySocket.receiveRtcSignal hit"); + + if (window.RelayRtc?.handleRtcSignal) { + LogMessage("Forwarding RTC signal to RelayRtc.handleRtcSignal"); + return window.RelayRtc.handleRtcSignal(rawJson); + } + + if (typeof window.handleRtcSignal === "function") { + LogMessage("Forwarding RTC signal to window.handleRtcSignal"); + return window.handleRtcSignal(rawJson); + } + + LogMessage("No RTC signal handler registered."); + } +}; + +window.RelaySocket = RelaySocket; \ No newline at end of file diff --git a/RelayClient/Resources/Raw/wwwroot/rtc.js b/RelayClient/Resources/Raw/wwwroot/rtc.js new file mode 100644 index 0000000..1d1476e --- /dev/null +++ b/RelayClient/Resources/Raw/wwwroot/rtc.js @@ -0,0 +1,225 @@ +const peerConnections = {}; + +async function joinChannelCall() { + LogMessage("Current username: " + currentUsername); + LogMessage("Current channel: " + currentChannelId); + + if (!currentUsername || !currentChannelId) { + LogMessage("Cannot join RTC: missing username or channel."); + return; + } + + await RelaySocket.joinRtcChannel(); + await Media.ensureLocalMedia(); + + const participants = await RelaySocket.getRtcParticipants(); + + LogMessage("Participants: " + JSON.stringify(participants)); + + const existingUsers = participants.filter(x => x !== currentUsername); + + if (existingUsers.length === 0) { + LogMessage("Joined call as first participant. Waiting for others..."); + return; + } + + for (const username of existingUsers) { + await sendOffer(username); + } +} + +async function sendOffer(username) { + const pc = await ensurePeerConnectionForUser(username); + + await Media.applyLocalStreamToPeerConnection(pc, username); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + await RelaySocket.sendRtcSignal({ + type: "rtc_offer", + channelId: currentChannelId, + from: currentUsername, + to: username, + sdp: offer.sdp + }); + + LogMessage(`Sent offer to ${username}`); +} + +async function handleRtcSignal(rawJson) { + try { + const msg = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; + + if (!msg || !msg.type) return; + if (msg.from === currentUsername) return; + + if (msg.to && msg.to !== currentUsername) { + LogMessage(`Ignoring RTC signal meant for ${msg.to}`); + return; + } + + LogMessage(`Received signal: ${msg.type} from ${msg.from}`); + + if (msg.type === "rtc_offer") { + await handleOffer(msg); + return; + } + + if (msg.type === "rtc_answer") { + await handleAnswer(msg); + return; + } + + if (msg.type === "rtc_ice") { + await handleIce(msg); + return; + } + + if (msg.type === "rtc_leave") { + closePeerConnection(msg.from); + return; + } + + LogMessage("Unhandled RTC signal type: " + msg.type); + } catch (err) { + LogMessage("handleRtcSignal failed: " + err); + } +} + +async function handleOffer(msg) { + const pc = await ensurePeerConnectionForUser(msg.from); + + await Media.ensureLocalMedia(); + await Media.applyLocalStreamToPeerConnection(pc, msg.from); + + await pc.setRemoteDescription({ + type: "offer", + sdp: msg.sdp + }); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + await RelaySocket.sendRtcSignal({ + type: "rtc_answer", + channelId: currentChannelId, + from: currentUsername, + to: msg.from, + sdp: answer.sdp + }); + + LogMessage(`Sent answer to ${msg.from}`); +} + +async function handleAnswer(msg) { + const pc = peerConnections[msg.from]; + + if (!pc) { + LogMessage(`No peer connection found for answer from ${msg.from}`); + return; + } + + await pc.setRemoteDescription({ + type: "answer", + sdp: msg.sdp + }); + + LogMessage(`Applied answer from ${msg.from}`); +} + +async function handleIce(msg) { + const pc = peerConnections[msg.from]; + + if (!pc) { + LogMessage(`No peer connection found for ICE from ${msg.from}`); + return; + } + + if (!msg.candidate) return; + + await pc.addIceCandidate(msg.candidate); + + LogMessage(`Applied ICE from ${msg.from}`); +} + +async function ensurePeerConnectionForUser(username) { + if (peerConnections[username]) { + return peerConnections[username]; + } + + const pc = new RTCPeerConnection(configuration); + peerConnections[username] = pc; + + pc.onicecandidate = async event => { + if (!event.candidate) return; + + await RelaySocket.sendRtcSignal({ + type: "rtc_ice", + channelId: currentChannelId, + from: currentUsername, + to: username, + candidate: JSON.stringify(event.candidate) + }); + }; + + pc.ontrack = event => { + LogMessage(`Remote track received from ${username}`); + + const stream = event.streams[0]; + if (!stream) return; + + Media.attachRemoteStream(username, stream); + }; + + pc.onconnectionstatechange = () => { + LogMessage(`Connection ${username}: ${pc.connectionState}`); + + if ( + pc.connectionState === "failed" || + pc.connectionState === "closed" || + pc.connectionState === "disconnected" + ) { + closePeerConnection(username); + } + }; + + return pc; +} + +async function leaveChannelCall() { + await RelaySocket.sendRtcSignal({ + type: "rtc_leave", + channelId: currentChannelId, + from: currentUsername + }); + + for (const username of Object.keys(peerConnections)) { + closePeerConnection(username); + } + + await RelaySocket.leaveRtcChannel(); + + LogMessage("Left RTC channel"); +} + +function closePeerConnection(username) { + const pc = peerConnections[username]; + if (!pc) return; + + pc.close(); + delete peerConnections[username]; + + Media.removeRemoteStream(username); + + LogMessage(`Closed RTC connection with ${username}`); +} + +window.RelayRtc = { + joinChannelCall, + leaveChannelCall, + handleRtcSignal, + peerConnections +}; + +window.handleRtcSignal = handleRtcSignal; \ No newline at end of file diff --git a/RelayClient/Services/RelaySocketClient.cs b/RelayClient/Services/RelaySocketClient.cs new file mode 100644 index 0000000..49c0f99 --- /dev/null +++ b/RelayClient/Services/RelaySocketClient.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using RelayClient.Crypto; +using RelayShared.Services; +using WebSocketSharp; + +namespace RelayClient.Services; + +public sealed class RelaySocketClient +{ + private readonly string _username; + private readonly WebSocket _socket; + + public string? ServerPublicKey { get; private set; } + + public event Action? RawMessageReceived; + public event Action? ChannelListReceived; + public event Action? EncryptedChatReceived; + public event Action? EncryptedRtcSignalReceived; + public event Action? ServerPublicKeyReceived; + public event Action? Log; + + public RelaySocketClient(string username, string url = "ws://localhost:1337/") + { + _username = username; + _socket = new WebSocket(url); + _socket.OnMessage += OnMessage; + } + + public void Connect() + { + _socket.Connect(); + + var publicKey = KeyStorage.LoadPublicKey(_username); + + SendRaw($"REGISTER_KEY|{_username}|{publicKey}"); + SendRaw("GET_SERVER_KEY"); + SendRaw("GET_CHANNELS"); + } + + public void SendRaw(string message) + { + if (_socket.ReadyState == WebSocketState.Open) + _socket.Send(message); + } + + public void SendJson(T payload) + { + SendRaw(JsonSerializer.Serialize(payload)); + } + + public void Disconnect() + { + _socket.OnMessage -= OnMessage; + + if (_socket.ReadyState == WebSocketState.Open) + _socket.Close(); + } + + private void OnMessage(object? sender, MessageEventArgs e) + { + if (e.Data.StartsWith("SERVER:REGISTERED_KEY:")) + { + Log?.Invoke(e.Data); + return; + } + + RawMessageReceived?.Invoke(e.Data); + Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}"); + + try + { + using var doc = JsonDocument.Parse(e.Data); + var root = doc.RootElement; + + if (!root.TryGetProperty("Type", out var typeElement)) + return; + + var type = (SignalType)typeElement.GetInt32(); + + switch (type) + { + case SignalType.ChannelList: + { + var channelList = JsonSerializer.Deserialize(e.Data); + if (channelList is not null) + ChannelListReceived?.Invoke(channelList); + + return; + } + + case SignalType.ServerPublicKey: + { + var serverKeyMessage = JsonSerializer.Deserialize(e.Data); + if (serverKeyMessage is not null) + { + ServerPublicKey = serverKeyMessage.PublicKey; + ServerPublicKeyReceived?.Invoke(serverKeyMessage.PublicKey); + } + + return; + } + + case SignalType.EncryptedSignal: + { + var payload = JsonSerializer.Deserialize(e.Data); + if (payload is not null) + EncryptedRtcSignalReceived?.Invoke(payload); + + return; + } + + case SignalType.EncryptedChat: + { + var payload = JsonSerializer.Deserialize(e.Data); + if (payload is not null) + EncryptedChatReceived?.Invoke(payload); + + return; + } + } + } + catch (Exception ex) + { + Log?.Invoke($"[{_username}] failed to process websocket message: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/RelayClient/Services/RtcBridgeService.cs b/RelayClient/Services/RtcBridgeService.cs new file mode 100644 index 0000000..83dacb0 --- /dev/null +++ b/RelayClient/Services/RtcBridgeService.cs @@ -0,0 +1,224 @@ +using System.Text.Json; +using RelayClient.Crypto; +using RelayShared.Rtc; +using RelayShared.Services; + +namespace RelayClient.Services; + +public sealed class RtcBridgeService +{ + private readonly string _username; + private readonly RelaySocketClient _socket; + private readonly HybridWebView _hybridWebView; + private readonly Func _getCurrentChannelId; + private readonly Action _sendRawToWebView; + + public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView, + Func getCurrentChannelId, Action sendRawToWebView) + { + _username = username; + _socket = socket; + _hybridWebView = hybridWebView; + _getCurrentChannelId = getCurrentChannelId; + _sendRawToWebView = sendRawToWebView; + } + + public Task JoinRtcChannel() + { + var channelId = _getCurrentChannelId(); + + if (string.IsNullOrWhiteSpace(channelId)) + return Task.CompletedTask; + + _socket.SendRaw($"RTC_JOIN_CHANNEL|{_username}|{channelId}"); + return Task.CompletedTask; + } + + public void LeaveRtcChannel() + { + var channelId = _getCurrentChannelId(); + + if (string.IsNullOrWhiteSpace(channelId)) + return; + + _socket.SendRaw($"RTC_LEAVE_CHANNEL|{_username}|{channelId}"); + } + + 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; + + 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); + } + } + + public async Task GetRtcParticipants() + { + var channelId = _getCurrentChannelId(); + + if (string.IsNullOrWhiteSpace(channelId)) + return "[]"; + + var participants = await ServerAPI.GetRtcParticipantsAsync(channelId); + return JsonSerializer.Serialize(participants ?? []); + } + + public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload) + { + var currentChannelId = _getCurrentChannelId(); + + if (payload.ChannelId != currentChannelId) + return; + + if (payload.SenderUsername == _username) + 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); + } + catch (Exception ex) + { + _sendRawToWebView("RTC signal parse failed: " + ex.Message); + return; + } + + if (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(decryptedJson); + } + + 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; + } + + private Task SendRtcSignalToJsAsync(string rawJson) + { + MainThread.BeginInvokeOnMainThread(async () => + { + try + { + var jsArg = JsonSerializer.Serialize(rawJson); + + 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); + }} + "); + } + catch (Exception ex) + { + _sendRawToWebView("SendRtcSignalToJsAsync failed: " + ex.Message); + } + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/RelayServer/Endpoints/RtcEndpoints.cs b/RelayServer/Endpoints/RtcEndpoints.cs index cebccac..5d0058c 100644 --- a/RelayServer/Endpoints/RtcEndpoints.cs +++ b/RelayServer/Endpoints/RtcEndpoints.cs @@ -1,6 +1,7 @@ using System.Text.Json; using RelayShared.Rtc; using RelayServer.Services.Rtc; +using RelayShared.Services; namespace RelayServer.Endpoints; diff --git a/RelayServer/RelayServer.csproj b/RelayServer/RelayServer.csproj index 0943ae8..88a35cc 100644 --- a/RelayServer/RelayServer.csproj +++ b/RelayServer/RelayServer.csproj @@ -17,8 +17,4 @@ - - - - diff --git a/RelayServer/Services/Chat/ChatSocketBehavior.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs index 659e4ac..92d8cc5 100644 --- a/RelayServer/Services/Chat/ChatSocketBehavior.cs +++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs @@ -6,7 +6,7 @@ using RelayServer.Services.Rtc; using WebSocketSharp; using WebSocketSharp.Server; using ErrorEventArgs = WebSocketSharp.ErrorEventArgs; -using RelayShared.Rtc; +using RelayShared.Services; namespace RelayServer.Services.Chat; diff --git a/RelayServer/Services/Rtc/RtcCallService.cs b/RelayServer/Services/Rtc/RtcCallService.cs index ec3775f..8bb3cde 100644 --- a/RelayServer/Services/Rtc/RtcCallService.cs +++ b/RelayServer/Services/Rtc/RtcCallService.cs @@ -1,5 +1,6 @@ using RelayShared.Rtc; using SurrealDb.Net; +using RelayShared.Rtc; namespace RelayServer.Services.Rtc; diff --git a/RelayShared/Rtc/RTCDatabase.cs b/RelayShared/Rtc/RTCDatabase.cs new file mode 100644 index 0000000..707a63c --- /dev/null +++ b/RelayShared/Rtc/RTCDatabase.cs @@ -0,0 +1,40 @@ +using SurrealDb.Net.Models; + +namespace RelayShared.Rtc; + +public sealed class DBActiveCall : Record +{ + public string ChannelId { get; set; } = string.Empty; + public string? OfferUser { get; set; } + public RtcSessionDescription? Offer { get; set; } + public RtcSessionDescription? Answer { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsActive { get; set; } + public string[] IceCandidates { get; set; } = []; //TODO: Should be array of DBIceCandidates IDs +} + +public sealed class DBOffer : Record +{ + public string ChannelId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Sdp { get; set; } = string.Empty; +} +public sealed class DBAnswer : Record +{ + public string ChannelId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Sdp { get; set; } = string.Empty; +} + +public class DBIceCandidate : Record +{ + public required string ChannelId { get; set; } + public required string Username { get; set; } + public required string Candidate { get; set; } + public string? SdpMid { get; set; } + public int? SdpMLineIndex { get; set; } + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/RelayShared/Rtc/RTCTransmissions.cs b/RelayShared/Rtc/RTCTransmissions.cs new file mode 100644 index 0000000..d9bc83d --- /dev/null +++ b/RelayShared/Rtc/RTCTransmissions.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Serialization; +using RelayShared.Services; + +namespace RelayShared.Rtc; + +public sealed class RtcSessionDescription +{ + public string Type { get; set; } = string.Empty; + public string Sdp { get; set; } = string.Empty; +} + +public sealed class RtcOffer +{ + public string ChannelId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public RtcSessionDescription SessionDescription { get; set; } = new(); +} + +public sealed class RtcAnswer +{ + public string ChannelId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public RtcSessionDescription SessionDescription { get; set; } = new(); +} +public class RtcIceCandidate +{ + public required string ChannelId { get; set; } + public required string Username { get; set; } + public required IceCandidate Candidate { get; set; } +} + +public class IceCandidate +{ + public required string candidate { get; set; } + public required string sdpMid { get; set; } + public required int sdpMLineIndex { get; set; } + public required string usernameFragment { get; set; } + +} + +public sealed class RtcJoinRequest +{ + public string ChannelId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; +} + +public sealed class RtcJoinResponse +{ + public string ChannelId { get; set; } = string.Empty; + public string[] Participants { get; set; } = []; +} + +public sealed class RtcLeaveRequest +{ + public string ChannelId { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; +} +public sealed class RtcNotificationMessage //TODO: Review for removal +{ + public SignalType? Type { get; set; } + public string? ChannelId { get; set; } + public string? Username { get; set; } + public string? Direction { get; set; } +} +public sealed class RtcSignalMessage //TODO: Review for removal. +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("channelId")] + public string ChannelId { get; set; } = string.Empty; + + [JsonPropertyName("sdp")] + public string? Sdp { get; set; } + + [JsonPropertyName("candidate")] + public string? Candidate { get; set; } + + [JsonPropertyName("sdpMid")] + public string? SdpMid { get; set; } + + [JsonPropertyName("sdpMLineIndex")] + public int? SdpMLineIndex { get; set; } + + [JsonPropertyName("isInitiator")] + public bool IsInitiator { get; set; } +} \ No newline at end of file diff --git a/RelayShared/Rtc/RtcModels.cs b/RelayShared/Rtc/RtcModels.cs deleted file mode 100644 index e167c08..0000000 --- a/RelayShared/Rtc/RtcModels.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System.Text.Json.Serialization; -using RelayShared.Rtc; -using SurrealDb.Net.Models; - -#region Resharper Stuff -// ReSharper disable ClassNeverInstantiated.Global -// ReSharper disable PropertyCanBeMadeInitOnly.Global -// ReSharper disable InconsistentNaming -#endregion - -namespace RelayShared.Rtc; - -public enum SignalType -{ - Offer, - Answer, - Candidate, - OfferUpdated, - AnswerUpdated, - CandidateAdded, - CallLeft, - ChannelList, - ServerPublicKey, - EncryptedSignal, - EncryptedChat, - ClientEncryptedChat -} - -public enum ChannelType -{ - Text, //Default channel type, handles text, links, files*, all in a linear live chat format - Voice, //Used for general voice and video calls, utilizes WebRTC in its intended use - File, //File browser for connected text channels, used for browsing files rather than scrolling through text channel - Forum, //Specific forum posts, meant to keep conversations grouped and on topic while keeping all in an easy to find place - Stage //Used for announcements and presentations, voice/video call utilizing a modified WebRTC protocol through server -} -public sealed class RtcSignalMessage -{ - [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; - - [JsonPropertyName("from")] - public string From { get; set; } = string.Empty; - - [JsonPropertyName("to")] - public string To { get; set; } = string.Empty; - - [JsonPropertyName("channelId")] - public string ChannelId { get; set; } = string.Empty; - - [JsonPropertyName("sdp")] - public string? Sdp { get; set; } - - [JsonPropertyName("candidate")] - public string? Candidate { get; set; } - - [JsonPropertyName("sdpMid")] - public string? SdpMid { get; set; } - - [JsonPropertyName("sdpMLineIndex")] - public int? SdpMLineIndex { get; set; } - - [JsonPropertyName("isInitiator")] - public bool IsInitiator { get; set; } -} - -public sealed class RtcNotificationMessage -{ - public SignalType? Type { get; set; } - public string? ChannelId { get; set; } - public string? Username { get; set; } - public string? Direction { get; set; } -} - -public sealed class ServerPublicKeyMessage -{ - public SignalType Type { get; set; } = SignalType.ServerPublicKey; - public string PublicKey { get; set; } = string.Empty; -} - -public sealed class SocketRtcSignalMessage -{ - public SignalType Type { get; set; } - public string SenderUsername { get; set; } = string.Empty; - public string ChannelId { get; set; } = string.Empty; - public string CipherText { get; set; } = string.Empty; - public string Nonce { get; set; } = string.Empty; - public string Tag { get; set; } = string.Empty; - public string EncryptedKey { get; set; } = string.Empty; -} - -public sealed class SocketEncryptedMessage -{ - public SignalType Type { get; set; } = SignalType.EncryptedChat; - public string SenderUsername { get; set; } = string.Empty; - public string RecipientUsername { get; set; } = string.Empty; - public string ChannelId { get; set; } = string.Empty; - public string CipherText { get; set; } = string.Empty; - public string Nonce { get; set; } = string.Empty; - public string Tag { get; set; } = string.Empty; - public string EncryptedKey { get; set; } = string.Empty; -} - -public sealed class ChannelItem -{ - public string ChannelId { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - - public ChannelType Type { get; set; } - - public string Group { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } -} - -public sealed class SocketChannelList -{ - public SignalType Type { get; set; } = SignalType.ChannelList; - public List Channels { get; set; } = []; -} - -public sealed class RtcJoinRequest -{ - public string ChannelId { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; -} - -public sealed class RtcJoinResponse -{ - public string ChannelId { get; set; } = string.Empty; - public bool HasActiveCall { get; set; } - public bool IsOfferer { get; set; } - public string? OfferUser { get; set; } - public RtcSessionDescription? OfferSdp { get; set; } -} - -public sealed class RtcLeaveRequest -{ - public string ChannelId { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; -} - -public sealed class RtcSessionDescription -{ - public string Type { get; set; } = string.Empty; - public string Sdp { get; set; } = string.Empty; -} - -public sealed class RtcOffer -{ - public string ChannelId { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public RtcSessionDescription SessionDescription { get; set; } = new(); -} - -public sealed class RtcAnswer -{ - public string ChannelId { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public RtcSessionDescription SessionDescription { get; set; } = new(); -} - -public class DBIceCandidate : Record -{ - public required string ChannelId { get; set; } - public required string Username { get; set; } - public required string Candidate { get; set; } - public string? SdpMid { get; set; } - public int? SdpMLineIndex { get; set; } - // public required string Direction { get; set; } // "offer" or "answer" - public DateTime CreatedAt { get; set; } -} - -public class RtcIceCandidate -{ - public required string ChannelId { get; set; } - public required string Username { get; set; } - public required IceCandidate Candidate { get; set; } -} - -public class IceCandidate -{ - public required string candidate { get; set; } - public required string sdpMid { get; set; } - public required int sdpMLineIndex { get; set; } - public required string usernameFragment { get; set; } - -} - -public sealed class DBActiveCall : Record -{ - public string ChannelId { get; set; } = string.Empty; - public string? OfferUser { get; set; } - public RtcSessionDescription? Offer { get; set; } - public RtcSessionDescription? Answer { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - public bool IsActive { get; set; } -} \ No newline at end of file diff --git a/RelayShared/Services/ChannelEnums.cs b/RelayShared/Services/ChannelEnums.cs new file mode 100644 index 0000000..73e1d00 --- /dev/null +++ b/RelayShared/Services/ChannelEnums.cs @@ -0,0 +1,10 @@ +namespace RelayShared.Services; + +public enum ChannelType +{ + Text, //Default channel type, handles text, links, files*, all in a linear live chat format + Voice, //Used for general voice and video calls, utilizes WebRTC in its intended use + File, //File browser for connected text channels, used for browsing files rather than scrolling through text channel + Forum, //Specific forum posts, meant to keep conversations grouped and on topic while keeping all in an easy to find place + Stage //Used for announcements and presentations, voice/video call utilizing a modified WebRTC protocol through server +} \ No newline at end of file diff --git a/RelayShared/Services/ChannelTransmissions.cs b/RelayShared/Services/ChannelTransmissions.cs new file mode 100644 index 0000000..99da406 --- /dev/null +++ b/RelayShared/Services/ChannelTransmissions.cs @@ -0,0 +1,18 @@ +namespace RelayShared.Services; + +public sealed class ChannelItem +{ + public string ChannelId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + + public ChannelType Type { get; set; } + + public string Group { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} + +public sealed class SocketChannelList +{ + public SignalType Type { get; set; } = SignalType.ChannelList; + public List Channels { get; set; } = []; +} \ No newline at end of file diff --git a/RelayShared/Services/SocketTransmissions.cs b/RelayShared/Services/SocketTransmissions.cs new file mode 100644 index 0000000..ccf82df --- /dev/null +++ b/RelayShared/Services/SocketTransmissions.cs @@ -0,0 +1,48 @@ +namespace RelayShared.Services; + +//TODO: review name of file, potentially rename for Encryption services rather than sockets + +public sealed class SocketRtcSignalMessage +{ + public SignalType Type { get; set; } + public string SenderUsername { get; set; } = string.Empty; + public string ChannelId { get; set; } = string.Empty; + public string CipherText { get; set; } = string.Empty; + public string Nonce { get; set; } = string.Empty; + public string Tag { get; set; } = string.Empty; + public string EncryptedKey { get; set; } = string.Empty; +} + +public sealed class SocketEncryptedMessage +{ + public SignalType Type { get; set; } = SignalType.EncryptedChat; + public string SenderUsername { get; set; } = string.Empty; + public string RecipientUsername { get; set; } = string.Empty; + public string ChannelId { get; set; } = string.Empty; + public string CipherText { get; set; } = string.Empty; + public string Nonce { get; set; } = string.Empty; + public string Tag { get; set; } = string.Empty; + public string EncryptedKey { get; set; } = string.Empty; +} + +public sealed class ServerPublicKeyMessage +{ + public SignalType Type { get; set; } = SignalType.ServerPublicKey; + public string PublicKey { get; set; } = string.Empty; +} + +public enum SignalType +{ + Offer, + Answer, + Candidate, + OfferUpdated, + AnswerUpdated, + CandidateAdded, + CallLeft, + ChannelList, + ServerPublicKey, + EncryptedSignal, + EncryptedChat, + ClientEncryptedChat +} \ No newline at end of file