Added WebRTC stuff - Needs Testing.

This commit is contained in:
2026-03-29 15:18:57 -04:00
parent 0bb3aa28b1
commit 8c6724038a
8 changed files with 417 additions and 170 deletions

View File

@@ -15,7 +15,7 @@ public partial class MainPage : ContentPage
private string? _currentChannelName; private string? _currentChannelName;
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new(); private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
private readonly List<ChannelItem> _channels = new(); private readonly List<ChannelItem> _channels = [];
public MainPage(string username) public MainPage(string username)
{ {
@@ -42,6 +42,11 @@ public partial class MainPage : ContentPage
_wsc.Send("GET_CHANNELS"); _wsc.Send("GET_CHANNELS");
hybridWebView.SetInvokeJavaScriptTarget(this); hybridWebView.SetInvokeJavaScriptTarget(this);
Loaded += async (_, _) =>
{
await InitializeRtcPageAsync();
};
} }
private void SendButton_OnClicked(object? sender, EventArgs e) private void SendButton_OnClicked(object? sender, EventArgs e)
@@ -67,6 +72,12 @@ public partial class MainPage : ContentPage
return; return;
} }
if (string.IsNullOrWhiteSpace(_currentChannelId))
{
Console.WriteLine("No channel selected yet.");
return;
}
var encrypted = E2EeHelper.EncryptForRecipient(text, _serverPublicKey); var encrypted = E2EeHelper.EncryptForRecipient(text, _serverPublicKey);
var payload = new SocketEncryptedMessage var payload = new SocketEncryptedMessage
@@ -153,48 +164,78 @@ public partial class MainPage : ContentPage
return; return;
} }
if (type == "encrypted_rtc_signal")
{
var payload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(e.Data);
if (payload is null)
return;
if (payload.RecipientUsername != _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
);
MainThread.BeginInvokeOnMainThread(async () =>
{
await SendRtcSignalToJsAsync(decryptedJson);
});
return;
}
if (type != "encrypted_chat") if (type != "encrypted_chat")
return; return;
var payload = JsonSerializer.Deserialize<SocketEncryptedMessage>(e.Data); var pyload = JsonSerializer.Deserialize<SocketEncryptedMessage>(e.Data);
if (payload is null) if (pyload is null)
return; return;
if (payload.RecipientUsername != _username) if (pyload.RecipientUsername != _username)
return; return;
Console.WriteLine($"[{_username}] received encrypted payload for {payload.RecipientUsername}"); Console.WriteLine($"[{_username}] received encrypted payload for {pyload.RecipientUsername}");
var privateKey = KeyStorage.LoadPrivateKey(_username); var privKey = KeyStorage.LoadPrivateKey(_username);
var decryptedText = E2EeHelper.DecryptForRecipient( var decryptedText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload new EncryptedPayload
{ {
CipherText = payload.CipherText, CipherText = pyload.CipherText,
Nonce = payload.Nonce, Nonce = pyload.Nonce,
Tag = payload.Tag, Tag = pyload.Tag,
EncryptedKey = payload.EncryptedKey EncryptedKey = pyload.EncryptedKey
}, },
privateKey privKey
); );
Console.WriteLine($"[{_username}] decrypted message from {payload.SenderUsername}: {decryptedText}"); Console.WriteLine($"[{_username}] decrypted message from {pyload.SenderUsername}: {decryptedText}");
var message = new ChatMessage var message = new ChatMessage
{ {
SenderUsername = payload.SenderUsername, SenderUsername = pyload.SenderUsername,
Text = decryptedText, Text = decryptedText,
Timestamp = DateTime.Now Timestamp = DateTime.Now
}; };
if (!_messagesByChannel.ContainsKey(payload.ChannelId)) if (!_messagesByChannel.ContainsKey(pyload.ChannelId))
{ {
_messagesByChannel[payload.ChannelId] = []; _messagesByChannel[pyload.ChannelId] = [];
} }
_messagesByChannel[payload.ChannelId].Add(message); _messagesByChannel[pyload.ChannelId].Add(message);
if (payload.ChannelId == _currentChannelId) if (pyload.ChannelId == _currentChannelId)
{ {
MainThread.BeginInvokeOnMainThread(() => MainThread.BeginInvokeOnMainThread(() =>
{ {
@@ -296,14 +337,14 @@ public partial class MainPage : ContentPage
{ {
MessagesScrollView.IsVisible = true; MessagesScrollView.IsVisible = true;
RtcView.IsVisible = false; RtcView.IsVisible = false;
ViewSwapped.Text = "Swap to Message View"; ViewSwapped.Text = "Swap to Web View";
} }
else else
{ {
MessagesScrollView.IsVisible = false; MessagesScrollView.IsVisible = false;
RtcView.IsVisible = true; RtcView.IsVisible = true;
ViewSwapped.Text = "Swap to Web View"; ViewSwapped.Text = "Swap to Message View";
} }
} }
@@ -317,61 +358,55 @@ public partial class MainPage : ContentPage
await DisplayAlertAsync("Raw Message Received", e.Message, "OK"); await DisplayAlertAsync("Raw Message Received", e.Message, "OK");
} }
#region syncs public void SendRtcSignal(string json)
public async void DoSyncWork()
{ {
await DisplayAlertAsync("Sync Work", "Sync Work", "OK"); if (string.IsNullOrWhiteSpace(_serverPublicKey))
}
public async void DoSyncWorkParams(int i, string s)
{
await DisplayAlertAsync("Sync Work", $"{i}:{s}", "OK");
}
public string DoSyncWorkReturn()
{
return "Hello from C#!";
}
public SyncReturn DoSyncWorkParamsReturn(int i, string s)
{
return new SyncReturn
{ {
Message = $"Hello from C#! {s}", Console.WriteLine("Server public key not loaded yet.");
Value = i return;
}; }
}
#endregion
#region asyncs RtcSignalMessage? rtcSignal;
try
public async Task DoAsyncWork()
{
await Task.Delay(1000);
}
public async Task DoAsyncWorkParams(int i, string s)
{
await DisplayAlertAsync("Sync Work", $"{i}:{s}", "OK");
}
public async Task<string> DoAsyncWorkReturn()
{
return "Hello from C#!";
}
public async Task<SyncReturn> DoAsyncWorkParamsReturn(int i, string s)
{
await Task.Delay(1000);
return new SyncReturn
{ {
Message = $"Hello from C# ASync! {s}", rtcSignal = JsonSerializer.Deserialize<RtcSignalMessage>(json);
Value = i }
catch (Exception ex)
{
Console.WriteLine($"Failed to parse RTC signal from JS: {ex.Message}");
return;
}
if (rtcSignal is null)
return;
var encrypted = E2EeHelper.EncryptForRecipient(json, _serverPublicKey);
var payload = new SocketRtcSignalMessage
{
Type = "encrypted_rtc_signal",
SenderUsername = _username,
RecipientUsername = rtcSignal.To,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
}; };
_wsc.Send(JsonSerializer.Serialize(payload));
Console.WriteLine($"[{_username}] sent RTC signal: {rtcSignal.Type} -> {rtcSignal.To}");
} }
#endregion private async Task SendRtcSignalToJsAsync(string rawJson)
public class SyncReturn
{ {
public string? Message { get; set; } var jsArg = JsonSerializer.Serialize(rawJson);
public int Value { get; set; } await hybridWebView.EvaluateJavaScriptAsync($"window.handleRtcSignal({jsArg})");
}
private async Task InitializeRtcPageAsync()
{
var jsArg = JsonSerializer.Serialize(_username);
await hybridWebView.EvaluateJavaScriptAsync($"window.currentUsername = {jsArg};");
Console.WriteLine($"[{_username}] RTC page initialized.");
} }
} }

View File

@@ -0,0 +1,13 @@
namespace RelayClient.Models;
public class RtcSignalMessage
{
public required string Type { get; set; } // rtc_offer / rtc_answer / rtc_ice_candidate / rtc_call_request / rtc_call_accept / rtc_call_reject
public required string From { get; set; }
public required string To { get; set; }
public string? Sdp { get; set; }
public string? Candidate { get; set; }
public string? SdpMid { get; set; }
public int? SdpMLineIndex { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace RelayClient.Models;
public class SocketRtcSignalMessage
{
public required string Type { get; set; } // encrypted_rtc_signal
public required string SenderUsername { get; set; }
public required string RecipientUsername { get; set; }
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required string EncryptedKey { get; set; }
}

View File

@@ -9,116 +9,190 @@
<link rel="stylesheet" href="index.css"> <link rel="stylesheet" href="index.css">
<script src="_framework/hybridwebview.js"></script> <script src="_framework/hybridwebview.js"></script>
<script> <script>
let peerConnection = null;
let localStream = null;
let currentTarget = null;
let currentUsername = null;
function LogMessage(msg) { function LogMessage(msg) {
var messageLog = document.getElementById("messageLog"); const messageLog = document.getElementById("messageLog");
messageLog.value += '\r\n' + msg; messageLog.value += '\r\n' + msg;
} }
window.addEventListener( async function ensurePeerConnection() {
"HybridWebViewMessageReceived", if (peerConnection) return;
function (e) {
LogMessage("Raw message: " + e.detail.message); peerConnection = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
}); });
function AddNumbers(a, b) { peerConnection.onicecandidate = async (event) => {
var result = { if (!event.candidate || !currentTarget || !currentUsername) return;
"result": a + b,
"operationName": "Addition" const payload = {
type: "rtc_ice_candidate",
from: currentUsername,
to: currentTarget,
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
};
LogMessage("Sending ICE candidate");
await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]);
};
peerConnection.ontrack = (event) => {
LogMessage("Remote track received");
const remoteVideo = document.getElementById("remoteVideo");
remoteVideo.srcObject = event.streams[0];
};
peerConnection.onconnectionstatechange = () => {
LogMessage("Connection state: " + peerConnection.connectionState);
};
peerConnection.oniceconnectionstatechange = () => {
LogMessage("ICE connection state: " + peerConnection.iceConnectionState);
};
peerConnection.onicegatheringstatechange = () => {
LogMessage("ICE gathering state: " + peerConnection.iceGatheringState);
}; };
return result;
} }
var count = 0; async function ensureLocalMedia() {
if (localStream) return;
async function EvaluateMeWithParamsAndAsyncReturn(s1, s2) { try {
const response = await fetch("/asyncdata.txt"); localStream = await navigator.mediaDevices.getUserMedia({
if (!response.ok) { video: true,
throw new Error(`HTTP error: ${response.status}`); audio: true
});
const localVideo = document.getElementById("localVideo");
localVideo.srcObject = localStream;
for (const track of localStream.getTracks()) {
peerConnection.addTrack(track, localStream);
}
LogMessage("Local media initialized");
} catch (err) {
LogMessage("getUserMedia failed: " + err);
throw err;
} }
var jsonData = await response.json();
jsonData[s1] = s2;
const msg = 'JSON data is available: ' + JSON.stringify(jsonData);
window.HybridWebView.SendRawMessage(msg)
return jsonData;
} }
async function InvokeDoSyncWork() { async function startCall() {
LogMessage("Invoking DoSyncWork"); try {
await window.HybridWebView.InvokeDotNet('DoSyncWork'); currentTarget = document.getElementById("targetUser").value;
LogMessage("Invoked DoSyncWork");
if (!currentTarget) {
LogMessage("No target user set.");
return;
}
await ensurePeerConnection();
await ensureLocalMedia();
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
const payload = {
type: "rtc_offer",
from: currentUsername,
to: currentTarget,
sdp: offer.sdp
};
LogMessage("Sending offer to " + currentTarget);
await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]);
} catch (err) {
LogMessage("startCall failed: " + err);
}
} }
async function InvokeDoSyncWorkParams() { async function handleRtcSignal(rawJson) {
LogMessage("Invoking DoSyncWorkParams"); try {
await window.HybridWebView.InvokeDotNet('DoSyncWorkParams', [123, 'hello']); const msg = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson;
LogMessage("Invoked DoSyncWorkParams");
LogMessage("Received signal: " + msg.type + " from " + msg.from);
await ensurePeerConnection();
if (msg.type === "rtc_offer") {
currentTarget = msg.from;
LogMessage("Incoming call from " + msg.from);
await ensureLocalMedia();
await peerConnection.setRemoteDescription({
type: "offer",
sdp: msg.sdp
});
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
const payload = {
type: "rtc_answer",
from: currentUsername,
to: msg.from,
sdp: answer.sdp
};
LogMessage("Sending answer to " + msg.from);
await window.HybridWebView.InvokeDotNet("SendRtcSignal", [JSON.stringify(payload)]);
return;
}
if (msg.type === "rtc_answer") {
await peerConnection.setRemoteDescription({
type: "answer",
sdp: msg.sdp
});
LogMessage("Remote answer applied");
return;
}
if (msg.type === "rtc_ice_candidate") {
await peerConnection.addIceCandidate({
candidate: msg.candidate,
sdpMid: msg.sdpMid,
sdpMLineIndex: msg.sdpMLineIndex
});
LogMessage("Remote ICE candidate applied");
}
} catch (err) {
LogMessage("handleRtcSignal failed: " + err);
}
} }
async function InvokeDoSyncWorkReturn() { window.handleRtcSignal = handleRtcSignal;
LogMessage("Invoking DoSyncWorkReturn");
const retValue = await window.HybridWebView.InvokeDotNet('DoSyncWorkReturn');
LogMessage("Invoked DoSyncWorkReturn, return value: " + retValue);
}
async function InvokeDoSyncWorkParamsReturn() {
LogMessage("Invoking DoSyncWorkParamsReturn");
const retValue = await window.HybridWebView.InvokeDotNet('DoSyncWorkParamsReturn', [123, 'hello']);
LogMessage("Invoked DoSyncWorkParamsReturn, return value: message=" + retValue.Message + ", value=" + retValue.Value);
}
async function InvokeDoAsyncWork() {
LogMessage("Invoking DoAsyncWork");
await window.HybridWebView.InvokeDotNet('DoAsyncWork');
LogMessage("Invoked DoAsyncWork");
}
async function InvokeDoAsyncWorkParams() {
LogMessage("Invoking DoAsyncWorkParams");
await window.HybridWebView.InvokeDotNet('DoAsyncWorkParams', [123, 'hello']);
LogMessage("Invoked DoAsyncWorkParams");
}
async function InvokeDoAsyncWorkReturn() {
LogMessage("Invoking DoAsyncWorkReturn");
const retValue = await window.HybridWebView.InvokeDotNet('DoAsyncWorkReturn');
LogMessage("Invoked DoAsyncWorkReturn, return value: " + retValue);
}
async function InvokeDoAsyncWorkParamsReturn() {
LogMessage("Invoking DoAsyncWorkParamsReturn");
const retValue = await window.HybridWebView.InvokeDotNet('DoAsyncWorkParamsReturn', [123, 'hello']);
LogMessage("Invoked DoAsyncWorkParamsReturn, return value: message=" + retValue.Message + ", value=" + retValue.Value);
}
window.addEventListener("HybridWebViewMessageReceived", function (e) {
LogMessage("Raw message: " + e.detail.message);
});
</script> </script>
</head> </head>
<body> <body>
<div> <div>
Hybrid sample! <h3>Relay RTC Test</h3>
</div> </div>
<div>
<button onclick="window.HybridWebView.SendRawMessage('Message from JS! ' + (count++))">Send message to C#</button> <div>
</div> <label for="targetUser">Target User:</label>
<div> <input id="targetUser" type="text" value="Ru_Kira" />
<button onclick="InvokeDoSyncWork()">Call C# sync method (no params)</button> <button onclick="startCall()">Start Call</button>
<button onclick="InvokeDoSyncWorkParams()">Call C# sync method (params)</button> </div>
<button onclick="InvokeDoSyncWorkReturn()">Call C# method (no params) and get simple return value</button>
<button onclick="InvokeDoSyncWorkParamsReturn()">Call C# method (params) and get complex return value</button> <div style="margin-top: 10px;">
</div> <video id="localVideo" autoplay playsinline muted style="width: 320px; height: 240px; background: #111;"></video>
<div> <video id="remoteVideo" autoplay playsinline style="width: 320px; height: 240px; background: #111;"></video>
<button onclick="InvokeDoAsyncWork()">Call C# async method (no params)</button> </div>
<button onclick="InvokeDoAsyncWorkParams()">Call C# async method (params)</button>
<button onclick="InvokeDoAsyncWorkReturn()">Call C# async method (no params) and get simple return value</button> <div style="margin-top: 10px;">
<button onclick="InvokeDoAsyncWorkParamsReturn()">Call C# async method (params) and get complex return value</button> <textarea readonly id="messageLog" style="width: 90%; height: 12em;"></textarea>
</div> </div>
<div> </body>
Log: <textarea readonly id="messageLog" style="width: 80%; height: 10em;"></textarea>
</div>
<div>
Consider checking out this PDF: <a href="docs/sample.pdf">sample.pdf</a>
</div>
</body>
</html> </html>

View File

@@ -0,0 +1,13 @@
namespace RelayServer.Models;
public class RtcSignalMessage
{
public required string Type { get; set; } // rtc_offer / rtc_answer / rtc_ice_candidate / rtc_call_request / rtc_call_accept / rtc_call_reject
public required string From { get; set; }
public required string To { get; set; }
public string? Sdp { get; set; }
public string? Candidate { get; set; }
public string? SdpMid { get; set; }
public int? SdpMLineIndex { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace RelayServer.Models;
public class SocketRtcSignalMessage
{
public required string Type { get; set; } // encrypted_rtc_signal
public required string SenderUsername { get; set; }
public required string RecipientUsername { get; set; }
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required string EncryptedKey { get; set; }
}

View File

@@ -42,6 +42,22 @@ public class ChatTest : WebSocketBehavior
return; return;
} }
SocketRtcSignalMessage? rtcProbe = null;
try
{
rtcProbe = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
}
catch
{
// ignored
}
if (rtcProbe?.Type == "encrypted_rtc_signal")
{
HandleEncryptedRtcSignal(msg);
return;
}
HandleEncryptedClientMessage(msg); HandleEncryptedClientMessage(msg);
} }
@@ -323,4 +339,74 @@ public class ChatTest : WebSocketBehavior
return $"{table}:{recordId}"; return $"{table}:{recordId}";
} }
private void HandleEncryptedRtcSignal(string msg)
{
SocketRtcSignalMessage? clientPayload;
try
{
clientPayload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
}
catch
{
Console.WriteLine("Failed to parse encrypted RTC signal payload.");
return;
}
if (clientPayload is null || clientPayload.Type != "encrypted_rtc_signal")
return;
if (ClientKeyService is null || string.IsNullOrWhiteSpace(ServerPrivateKey))
{
Console.WriteLine("Server RTC crypto dependencies are not initialized.");
return;
}
string plainJson;
try
{
plainJson = E2EeHelper.DecryptForRecipient(
new EncryptedPayload
{
CipherText = clientPayload.CipherText,
Nonce = clientPayload.Nonce,
Tag = clientPayload.Tag,
EncryptedKey = clientPayload.EncryptedKey
},
ServerPrivateKey
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to decrypt RTC signal payload: {ex.Message}");
return;
}
var targetClient = Task.Run(async () => await ClientKeyService.GetByUsernameAsync(clientPayload.RecipientUsername))
.GetAwaiter()
.GetResult();
if (targetClient is null)
{
Console.WriteLine($"No target RTC client key found for {clientPayload.RecipientUsername}");
return;
}
var encrypted = E2EeHelper.EncryptForRecipient(plainJson, targetClient.PublicKey);
var outbound = new SocketRtcSignalMessage
{
Type = "encrypted_rtc_signal",
SenderUsername = clientPayload.SenderUsername,
RecipientUsername = clientPayload.RecipientUsername,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Sessions.Broadcast(JsonSerializer.Serialize(outbound));
}
} }

View File

@@ -64,19 +64,19 @@ Start-Sleep -Seconds 5
& '$clientExe' --user Ru_Kira & '$clientExe' --user Ru_Kira
"@ "@
$testScript = New-TabScript -Name "Test" -Content @" #$testScript = New-TabScript -Name "Test" -Content @"
Set-Location '$root' #Set-Location '$root'
Start-Sleep -Seconds 25 #Start-Sleep -Seconds 25
& '$clientExe' --user Test #& '$clientExe' --user Test
"@ #"@
$wtArgs = @( $wtArgs = @(
"new-tab --title `"SurrealDB`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$dockerScript`"", "new-tab --title `"SurrealDB`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$dockerScript`"",
"new-tab --title `"RelayCore`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$coreScript`"", "new-tab --title `"RelayCore`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$coreScript`"",
"new-tab --title `"RelayServer`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$serverScript`"", "new-tab --title `"RelayServer`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$serverScript`"",
"new-tab --title `"Keeper317`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$keeperScript`"", "new-tab --title `"Keeper317`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$keeperScript`"",
"new-tab --title `"Ru_Kira`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$kiraScript`"", "new-tab --title `"Ru_Kira`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$kiraScript`""
"new-tab --title `"Test`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$testScript`"" #"new-tab --title `"Test`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$testScript`""
) -join " ; " ) -join " ; "
Write-Host "" Write-Host ""