feat(calls): implement Linux LAN peer calls over SRTP

Reuse existing ChatHub consent and Podcast LibVLC capture for direct bidirectional H.264/AAC media, with embedded call controls and cleanup. Require HTTPS key exchange and reject IP-masking policies for this opt-in LAN/VPN transport.

Add native SRTP, signaling and lifecycle tests; document setup and remaining physical-device validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-26 03:05:23 +00:00
commit cc28ee8b29
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1394 additions and 29 deletions

View file

@ -15,6 +15,7 @@ public sealed class CallSignalingClientTests
var id = Guid.NewGuid();
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteLanAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcceptAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(id, new("offer", "sdp"), ct));
@ -50,5 +51,10 @@ public sealed class CallSignalingClientTests
var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay",
SdpMid: "video", SdpMLineIndex: 1);
Assert.Equal(signal, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(signal, options), options));
var lan = call with { RelayOnly = false, Transport = CallTransports.LanSrtp };
Assert.Equal(lan, JsonSerializer.Deserialize<CallSession>(JsonSerializer.Serialize(lan, options), options));
var endpoint = new CallSignal("rtp-endpoint",
Endpoint: new("192.168.1.2", 5004, new string('a', 32), new string('b', 28)));
Assert.Equal(endpoint, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(endpoint, options), options));
}
}

View file

@ -0,0 +1,192 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class LanCallViewModelTests
{
private static CallSession Session() => new(Guid.NewGuid(), "General", "alice", "bob", false,
DateTimeOffset.UtcNow.AddMinutes(1), CallTransports.LanSrtp);
private static LanCallViewModel Configure(Signaling signaling, Func<string, int, ILanCallMedia> create)
{
var vm = new LanCallViewModel(signaling, create)
{
Address = "127.0.0.1", Camera = new("Camera", "/dev/video0"),
Microphone = new("Micro", "default"), AllowAddressDisclosure = true,
Room = "General", Target = "bob"
};
vm.Addresses.Add("127.0.0.1");
return vm;
}
[AvaloniaFact]
public async Task No_capture_starts_before_explicit_acceptance_and_endpoint_exchange()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
Assert.Equal(0, created);
Assert.Empty(signaling.Sent);
await vm.AcceptCommand.ExecuteAsync(null);
await WaitAsync(() => vm.LocalVideo != null);
Assert.Equal(1, created);
Assert.False(media.Sending);
Assert.Single(signaling.Sent);
Assert.Equal("rtp-endpoint", signaling.Sent[0].Kind);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
Assert.Same(media.LocalVideo, vm.LocalVideo);
Assert.Same(media.RemoteVideo, vm.RemoteVideo);
await vm.HangupAsync();
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.LocalVideo);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Caller_waits_for_consent_and_interruption_releases_media_without_reconnecting()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
await vm.InviteCommand.ExecuteAsync(null);
Assert.True(vm.Busy);
Assert.Equal(0, created);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
signaling.Disconnect();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.RemoteVideo);
Assert.Contains("interrompue", vm.Status);
}
[AvaloniaFact]
public async Task Missing_disclosure_consent_or_devices_blocks_the_invitation()
{
var signaling = new Signaling();
await using var vm = Configure(signaling, (_, _) => throw new Exception("Must not capture"));
vm.AllowAddressDisclosure = false;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("Confirmez", vm.Status);
vm.AllowAddressDisclosure = true;
vm.Camera = null;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("camera", vm.Status);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Refusal_never_creates_media_and_late_signals_cannot_restart_capture()
{
var signaling = new Signaling();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return new FakeMedia(); });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
await vm.HangupAsync();
signaling.Ready();
signaling.RemoteEndpoint(signaling.Call.Id);
await Task.Delay(30, TestContext.Current.CancellationToken);
Assert.Equal(0, created);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Hangup_cancels_pending_native_start_without_waiting_for_startup_timeout()
{
var signaling = new Signaling();
var media = new FakeMedia { DelayStartup = true };
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
await vm.HangupAsync().WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Media_failure_ends_the_call_and_disposes_capture()
{
var signaling = new Signaling();
var media = new FakeMedia();
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
media.Fail();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Contains("test media failure", vm.Status);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
private static async Task WaitAsync(Func<bool> condition)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (!condition()) await Task.Delay(10, timeout.Token);
}
private sealed class FakeMedia : ILanCallMedia
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo { get; } = new Border();
public Control RemoteVideo { get; } = new Border();
public LanCallEndpoint Endpoint { get; } = new("127.0.0.1", 5004, new string('a', 32), new string('b', 28));
public Task Completion => _completion.Task;
public bool HasRemoteMedia => false;
public bool Sending { get; private set; }
public bool Disposed { get; private set; }
public bool DelayStartup { get; init; }
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
Sending = true;
if (DelayStartup) await Task.Delay(Timeout.Infinite, ct);
}
public void Fail() => _completion.TrySetException(new IOException("test media failure"));
public ValueTask DisposeAsync() { Disposed = true; _completion.TrySetResult(); return ValueTask.CompletedTask; }
}
private sealed class Signaling : ILanCallSignaling
{
public event Action<CallSession>? Invitation;
public event Action<CallAccepted>? Accepted;
public event Action<CallEnded>? Ended;
public event Action<Guid, CallSignal>? Signal;
public event Action? Interrupted;
public CallSession Call { get; } = Session();
public List<CallSignal> Sent { get; } = [];
public List<Guid> EndedCalls { get; } = [];
public int Invites { get; private set; }
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{ Invites++; return Task.FromResult(Call); }
public Task AcceptAsync(Guid id, CancellationToken ct = default) { Ready(); return Task.CompletedTask; }
public Task EndAsync(Guid id, CancellationToken ct = default)
{ EndedCalls.Add(id); Ended?.Invoke(new(id, "hangup")); return Task.CompletedTask; }
public Task SendAsync(Guid id, CallSignal signal, CancellationToken ct = default)
{ Sent.Add(signal); return Task.CompletedTask; }
public void Invite(LanCallViewModel _, CallSession call) => Invitation?.Invoke(call);
public void Ready() => Accepted?.Invoke(new(Call, []));
public void RemoteEndpoint(Guid id) => Signal?.Invoke(id, new("rtp-endpoint",
Endpoint: new("127.0.0.1", 5006, new string('c', 32), new string('d', 28))));
public void Disconnect() => Interrupted?.Invoke();
}
}

View file

@ -0,0 +1,113 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Tests;
public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
{
public static bool HasFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Theory(Skip = "Set POSTIT_TEST_MEDIA to an audio/video file; requires Linux LibVLC SRTP and H.264/AAC encoders.",
SkipUnless = nameof(HasFixture))]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Two_peers_decode_bidirectional_encrypted_audio_video_and_reject_wrong_keys(bool wrongKey, bool preview)
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
var secondPort = FreePort(firstPort);
await using var first = new LinuxLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var second = new LinuxLanCallMedia("127.0.0.1", secondPort, headless: true);
using var firstVideo = new DecodedVideo(first.Receiver);
using var secondVideo = new DecodedVideo(second.Receiver);
await first.StartReceivingAsync(ct);
await second.StartReceivingAsync(ct);
await Task.Delay(300, ct);
Media Source(LibVLC vlc)
{
var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
media.AddOption(":input-repeat=5");
return media;
}
await first.StartSendingAsync(wrongKey ? second.Endpoint with { Key = new string('0', 32) } : second.Endpoint,
Source, preview, ct);
await second.StartSendingAsync(first.Endpoint, Source, preview, ct);
var elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 8 : 20))
{
if (first.Completion.IsCompleted) await first.Completion;
if (second.Completion.IsCompleted) await second.Completion;
if (!wrongKey && HasMedia(first, firstVideo) && HasMedia(second, secondVideo) &&
first.HasRemoteMedia && second.HasRemoteMedia) break;
await Task.Delay(100, ct);
}
var a = first.Statistics;
var b = second.Statistics;
output.WriteLine($"A: audioSamples={firstVideo.AudioSamples}, rendered={firstVideo.Frames}; " +
$"B: audioSamples={secondVideo.AudioSamples}, rendered={secondVideo.Frames}; elapsed={elapsed.ElapsedMilliseconds}ms");
Assert.True(HasMedia(first, firstVideo), "First peer must decode received audio and video.");
Assert.True(first.HasRemoteMedia, "The UI must detect initial audio/video reception.");
if (wrongKey)
{
Assert.Equal(0, b.DecodedVideo);
Assert.Equal(0, b.DecodedAudio);
Assert.Equal(0, secondVideo.Frames);
Assert.Equal(0, secondVideo.AudioSamples);
Assert.False(second.HasRemoteMedia);
}
else
{
Assert.True(HasMedia(second, secondVideo), "Second peer must decode received audio and video.");
Assert.True(second.HasRemoteMedia);
}
}
private static bool HasMedia(LinuxLanCallMedia peer, DecodedVideo video) =>
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10;
private static int FreePort(int except = 0)
{
for (var port = 32000; port < 60000; port += 2)
{
if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
try { socket.Bind(new IPEndPoint(IPAddress.Loopback, port)); return port; }
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { }
}
throw new IOException("No free RTP port available.");
}
private sealed class DecodedVideo : IDisposable
{
private readonly IntPtr _pixels = Marshal.AllocHGlobal(640 * 480 * 4);
private int _frames;
private long _audioSamples;
public int Frames => Volatile.Read(ref _frames);
public long AudioSamples => Interlocked.Read(ref _audioSamples);
private readonly MediaPlayer _player;
public DecodedVideo(MediaPlayer player)
{
_player = player;
player.SetAudioFormat("S16N", 48000, 1);
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _audioSamples, count),
null, null, null, null);
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, _pixels);
return IntPtr.Zero;
}, null, (_, _) => Interlocked.Increment(ref _frames));
}
public void Dispose()
{
_player.Stop();
Marshal.FreeHGlobal(_pixels);
}
}
}

View file

@ -6,7 +6,7 @@ using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class CallSignalingClient : IDisposable
public sealed class CallSignalingClient : ILanCallSignaling, IDisposable
{
private readonly HubConnection _connection;
private readonly IDisposable[] _subscriptions;
@ -39,6 +39,12 @@ public sealed class CallSignalingClient : IDisposable
return _connection.InvokeAsync<CallSession>(CallHubMethods.Invite, room, user, ct);
}
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync<CallSession>(CallHubMethods.InviteLan, room, user, ct);
}
public Task AcceptAsync(Guid callId, CancellationToken ct = default)
{
EnsureConnected();

View file

@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public interface ILanCallSignaling
{
event Action<CallSession>? Invitation;
event Action<CallAccepted>? Accepted;
event Action<CallEnded>? Ended;
event Action<Guid, CallSignal>? Signal;
event Action? Interrupted;
Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default);
Task AcceptAsync(Guid callId, CancellationToken ct = default);
Task EndAsync(Guid callId, CancellationToken ct = default);
Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default);
}

View file

@ -0,0 +1,179 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class LinuxLanCallMedia : IAsyncDisposable
{
private readonly LibVLC _senderVlc;
private readonly LibVLC _receiverVlc;
private readonly Media _receiverMedia;
private Media? _senderMedia;
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _gate = new(1);
private bool _receiving;
private bool _disposed;
private Task? _stop;
public LanCallEndpoint Endpoint { get; }
public MediaPlayer Sender { get; }
public MediaPlayer Receiver { get; }
public Task Completion => _completion.Task;
public MediaStats Statistics => _receiverMedia.Statistics;
public bool HasRemoteMedia => !_disposed && Statistics.DecodedAudio > 0 && Statistics.DecodedVideo > 0;
public LinuxLanCallMedia(string localAddress, int port, bool headless = false)
{
if (!OperatingSystem.IsLinux())
throw new PlatformNotSupportedException("Les appels SRTP LAN sont disponibles sur Desktop Linux.");
Endpoint = new(localAddress, port, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)),
Convert.ToHexString(RandomNumberGenerator.GetBytes(14)));
if (!Endpoint.IsValid())
throw new ArgumentException("Adresse IPv4 privee et port UDP pair (1024-65534) requis.");
using (var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
reservation.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port));
Core.Initialize();
_senderVlc = headless ? CameraStreamer.CreateHeadlessLibVlc()
: new LibVLC("--avcodec-hw=none", "--aout=dummy", "--intf=dummy");
try
{
_receiverVlc = headless ? CameraStreamer.CreateHeadlessLibVlc()
: new LibVLC("--avcodec-hw=none", "--intf=dummy");
try
{
_receiverMedia = new Media(_receiverVlc, $"rtp://@{localAddress}:{port}", FromType.FromLocation);
_receiverMedia.AddOption(":network-caching=150");
_receiverMedia.AddOption(":rtcp-port=0");
_receiverMedia.AddOption(":srtp-key=" + Endpoint.Key);
_receiverMedia.AddOption(":srtp-salt=" + Endpoint.Salt);
Receiver = new MediaPlayer(_receiverVlc);
Sender = new MediaPlayer(_senderVlc);
Sender.EncounteredError += OnSenderError;
Receiver.EncounteredError += OnReceiverError;
Sender.EndReached += OnSenderEnded;
}
catch
{
_receiverMedia?.Dispose();
Receiver?.Dispose();
Sender?.Dispose();
_receiverVlc.Dispose();
throw;
}
}
catch
{
_senderVlc.Dispose();
throw;
}
}
public async Task StartReceivingAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_stop != null) throw new InvalidOperationException("Cet appel est arrete.");
if (_receiving) return;
ct.ThrowIfCancellationRequested();
if (!Receiver.Play(_receiverMedia))
throw new InvalidOperationException("Reception SRTP impossible. Verifiez adresse et port UDP.");
_receiving = true;
}
finally { _gate.Release(); }
}
public Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct) =>
StartSendingAsync(remote, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), true, ct);
internal async Task StartSendingAsync(LanCallEndpoint remote, Func<LibVLC, Media> source,
bool preview, CancellationToken ct)
{
if (!remote.IsValid())
throw new ArgumentException("Destination SRTP LAN invalide.", nameof(remote));
if (remote.Address == Endpoint.Address && remote.Port == Endpoint.Port)
throw new ArgumentException("Les deux pairs doivent avoir des ports distincts sur une meme adresse.");
await _gate.WaitAsync(ct);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_stop != null || _senderMedia != null)
throw new InvalidOperationException("La capture de cet appel est deja demarree ou arretee.");
_senderMedia = source(_senderVlc);
ConfigureOutput(_senderMedia, remote, preview);
ct.ThrowIfCancellationRequested();
if (!Sender.Play(_senderMedia))
throw new InvalidOperationException("Capture SRTP impossible. Verifiez camera, microphone et encodeurs H.264/AAC.");
}
finally { _gate.Release(); }
}
internal static void ConfigureOutput(Media media, LanCallEndpoint remote, bool preview)
{
if (!remote.IsValid()) throw new ArgumentException("Destination SRTP invalide.");
// Same capture/encoders as Podcasts, but no HLS files, segmentation or upload.
var encoding = "transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=30,bframes=0},"
+ "vb=1200,fps=30,acodec=mp4a,ab=96,channels=1,samplerate=48000}:"
+ $"rtp{{dst={remote.Address},port={remote.Port},mux=ts,rtcp-mux,"
+ $"key={remote.Key},salt={remote.Salt},caching=50}}";
media.AddOption(":sout=#" + (preview
? $"duplicate{{dst=display{{noaudio}},dst=\"{encoding}\"}}" : encoding));
}
private void OnSenderError(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("Capture/encodage SRTP en echec. Verifiez les peripheriques et encodeurs."));
private void OnReceiverError(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("Reception/decodage SRTP en echec. Verifiez le reseau LAN/VPN et les ports UDP."));
private void OnSenderEnded(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("La source audio-video s'est terminee."));
private Task StopCoreAsync() => _stop ??= Task.Run(() =>
{
try { Sender.Stop(); }
finally { Receiver.Stop(); }
});
internal async Task StopAsync()
{
await _gate.WaitAsync();
try
{
if (!_disposed) await StopCoreAsync();
}
finally { _gate.Release(); }
}
public async ValueTask DisposeAsync()
{
await _gate.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
Sender.EncounteredError -= OnSenderError;
Receiver.EncounteredError -= OnReceiverError;
Sender.EndReached -= OnSenderEnded;
try { await StopCoreAsync(); }
finally
{
Sender.Dispose();
Receiver.Dispose();
_senderMedia?.Dispose();
_receiverMedia.Dispose();
_senderVlc.Dispose();
_receiverVlc.Dispose();
_completion.TrySetResult();
}
}
finally { _gate.Release(); }
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using LibVLCSharp.Avalonia;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public interface ILanCallMedia : IAsyncDisposable
{
Control LocalVideo { get; }
Control RemoteVideo { get; }
LanCallEndpoint Endpoint { get; }
Task Completion { get; }
bool HasRemoteMedia { get; }
Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct);
}
public sealed class LinuxLanCallVideo : ILanCallMedia
{
private readonly LinuxLanCallMedia _media;
private readonly VideoView _local;
private readonly VideoView _remote;
private readonly TaskCompletionSource _localReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationTokenSource _lifetime = new();
private bool _receiverScheduled;
private bool _localScheduled;
private bool _disposed;
private readonly TaskCompletionSource _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo => _local;
public Control RemoteVideo => _remote;
public LanCallEndpoint Endpoint => _media.Endpoint;
public Task Completion { get; }
public bool HasRemoteMedia => !_disposed && _media.HasRemoteMedia;
public LinuxLanCallVideo(string localAddress, int port)
{
_media = new LinuxLanCallMedia(localAddress, port);
_local = new VideoView { MediaPlayer = _media.Sender, MinHeight = 140 };
_remote = new VideoView { MediaPlayer = _media.Receiver, MinHeight = 260 };
_local.LayoutUpdated += OnLocalLayout;
_remote.LayoutUpdated += OnRemoteLayout;
Completion = ObserveCompletionAsync();
}
private async Task ObserveCompletionAsync()
{
var completed = await Task.WhenAny(_media.Completion, _failure.Task);
await completed;
}
private void OnLocalLayout(object? sender, EventArgs e)
{
if (_localScheduled || _disposed || !_local.IsEffectivelyVisible ||
_local.Bounds.Width <= 0 || _local.Bounds.Height <= 0) return;
_localScheduled = true;
Dispatcher.UIThread.Post(() =>
{
if (_disposed) return;
_local.TryUpdateNativeControlPosition();
_localReady.TrySetResult();
}, DispatcherPriority.Loaded);
}
private void OnRemoteLayout(object? sender, EventArgs e)
{
if (_receiverScheduled || _disposed || !_remote.IsEffectivelyVisible ||
_remote.Bounds.Width <= 0 || _remote.Bounds.Height <= 0) return;
_receiverScheduled = true;
Dispatcher.UIThread.Post(StartReceiver, DispatcherPriority.Loaded);
}
private async void StartReceiver()
{
if (_disposed) return;
try
{
_remote.TryUpdateNativeControlPosition();
await _media.StartReceivingAsync(_lifetime.Token);
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { }
catch (Exception error) { _failure.TrySetException(error); }
}
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
await _localReady.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
await _media.StartSendingAsync(remote, camera, microphone, ct);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
_localReady.TrySetCanceled();
_local.LayoutUpdated -= OnLocalLayout;
_remote.LayoutUpdated -= OnRemoteLayout;
try { await _media.StopAsync(); }
finally
{
_local.MediaPlayer = null;
_remote.MediaPlayer = null;
try { await _media.DisposeAsync(); }
finally
{
_lifetime.Dispose();
_failure.TrySetResult();
}
}
}
}

View file

@ -39,6 +39,11 @@ namespace PostIt.ViewModels.Chat
public CallSignalingClient? Calls { get; private set; }
public bool SupportsLanCalls => OperatingSystem.IsLinux() && !OperatingSystem.IsAndroid();
[ObservableProperty]
public partial LanCallViewModel? LanCall { get; set; }
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
@ -83,6 +88,7 @@ namespace PostIt.ViewModels.Chat
partial void OnSelectedRoomChanged(string? value)
{
if (LanCall != null) LanCall.Room = value ?? "";
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
? msgs : _systemMessages;
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
@ -132,6 +138,13 @@ namespace PostIt.ViewModels.Chat
private async Task DisconnectAsync()
{
Exception? callError = null;
if (LanCall != null)
{
try { await LanCall.DisposeAsync(); }
catch (Exception error) { callError = error; }
finally { LanCall = null; }
}
Calls?.Dispose();
Calls = null;
if (_connection != null)
@ -146,7 +159,9 @@ namespace PostIt.ViewModels.Chat
_messagesByRoom.Clear();
_usersByRoom.Clear();
_systemMessages.Clear();
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
Status = new StatusNotice(callError == null ? "Déconnecté" :
"Déconnecté ; erreur de fermeture de l'appel : " + callError.Message,
callError == null ? StatusSeverity.Info : StatusSeverity.Error,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
@ -156,6 +171,11 @@ namespace PostIt.ViewModels.Chat
{
if (_connection != null)
{
if (LanCall != null)
{
await LanCall.DisposeAsync();
LanCall = null;
}
Calls?.Dispose();
Calls = null;
await _connection.DisposeAsync();
@ -200,8 +220,11 @@ namespace PostIt.ViewModels.Chat
.Build();
Calls = new CallSignalingClient(_connection);
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; moteur natif non disponible."));
if (SupportsLanCalls)
LanCall = new LanCallViewModel(Calls) { Room = SelectedRoom ?? "" };
else
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; transport non disponible sur cette plateforme."));
Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Appel terminé : {call.Reason}."));

View file

@ -0,0 +1,311 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.ViewModels.Chat;
public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
{
private readonly ILanCallSignaling _signaling;
private readonly Func<string, int, ILanCallMedia> _createMedia;
private readonly SemaphoreSlim _events = new(1);
private readonly CancellationTokenSource _lifetime = new();
private CancellationTokenSource? _callLifetime;
private ILanCallMedia? _media;
private CallSession? _call;
private bool _disposed;
private bool _accepting;
private bool _endpointReceived;
private string? _activeCamera;
private string? _activeMicrophone;
private string? _activeAddress;
private int _activePort;
public ObservableCollection<string> Addresses { get; } = [];
public ObservableCollection<CaptureDevice> Cameras { get; } = [];
public ObservableCollection<CaptureDevice> Microphones { get; } = [];
[ObservableProperty] public partial string? Address { get; set; }
[ObservableProperty] public partial string Port { get; set; } = "5004";
[ObservableProperty] public partial CaptureDevice? Camera { get; set; }
[ObservableProperty] public partial CaptureDevice? Microphone { get; set; }
[ObservableProperty] public partial bool AllowAddressDisclosure { get; set; }
[ObservableProperty] public partial string Target { get; set; } = "";
[ObservableProperty] public partial string Room { get; set; } = "";
[ObservableProperty] public partial string Status { get; set; } = "Appel Linux LAN/VPN : aucun appel.";
[ObservableProperty] public partial bool Incoming { get; set; }
[ObservableProperty] public partial bool Busy { get; set; }
[ObservableProperty] public partial bool Preparing { get; set; }
[ObservableProperty] public partial Control? LocalVideo { get; set; }
[ObservableProperty] public partial Control? RemoteVideo { get; set; }
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null)
{
_signaling = signaling;
_createMedia = createMedia ?? ((address, port) => new LinuxLanCallVideo(address, port));
signaling.Invitation += OnInvitation;
signaling.Accepted += OnAccepted;
signaling.Signal += OnSignal;
signaling.Ended += OnEnded;
signaling.Interrupted += OnInterrupted;
}
[RelayCommand]
private void RefreshDevices()
{
try
{
if (Busy) throw new InvalidOperationException("Terminez l'appel avant de changer les peripheriques.");
Addresses.Clear();
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address);
Address = Addresses.FirstOrDefault();
Cameras.Clear();
Microphones.Clear();
LibVLCSharp.Shared.Core.Initialize();
using var vlc = CameraStreamer.CreatePreferredLibVlc();
var streamer = new CameraStreamer(vlc);
foreach (var device in streamer.GetCaptureDevices()) Cameras.Add(new(device.Key, device.Value));
foreach (var device in streamer.GetAudioCaptureDevices()) Microphones.Add(new(device.Key, device.Value));
Camera = Cameras.FirstOrDefault();
Microphone = Microphones.FirstOrDefault();
Status = Cameras.Count == 0 ? "Aucune camera Linux detectee. Branchez une camera puis actualisez."
: "Selectionnez le pair, l'adresse LAN/VPN et les peripheriques. Utilisez un casque.";
}
catch (Exception error) { Status = "Peripheriques indisponibles : " + error.Message; }
}
private void ValidateSelection()
{
if (!AllowAddressDisclosure)
throw new InvalidOperationException("Confirmez le partage de votre adresse LAN/VPN avec le pair.");
if (Camera == null || Microphone == null)
throw new InvalidOperationException("Une camera et un microphone sont requis.");
if (Address == null || !Addresses.Contains(Address) || !LanCallNetwork.IsPrivateIPv4(Address) ||
!int.TryParse(Port, out var port) || port is < 1024 or > 65534 || port % 2 != 0)
throw new InvalidOperationException("Selectionnez une adresse locale et un port UDP pair (1024-65534).");
_activeCamera = Camera.Value;
_activeMicrophone = Microphone.Value;
_activeAddress = Address;
_activePort = port;
}
[RelayCommand]
private Task InviteAsync() => RunAsync(async () =>
{
if (Busy) throw new InvalidOperationException("Un appel est deja en cours.");
ValidateSelection();
if (string.IsNullOrWhiteSpace(Target) || string.IsNullOrWhiteSpace(Room))
throw new InvalidOperationException("Selectionnez une salle jointe et un autre utilisateur.");
Busy = true;
_call = await _signaling.InviteLanAsync(Room, Target.Trim(), _lifetime.Token);
Status = $"Invitation envoyee a {_call.Callee} ; attente du consentement.";
});
[RelayCommand]
private Task AcceptAsync() => RunAsync(async () =>
{
if (_call == null || !Incoming)
throw new InvalidOperationException("Aucune invitation a accepter.");
ValidateSelection();
Incoming = false;
_accepting = true;
await _signaling.AcceptAsync(_call.Id, _lifetime.Token);
Status = "Invitation acceptee ; preparation des medias.";
});
[RelayCommand]
public Task HangupAsync()
{
_callLifetime?.Cancel();
return RunAsync(() => StopAsync("Appel termine.", true));
}
private void OnInvitation(CallSession call) => Queue(async () =>
{
if (call.Transport != CallTransports.LanSrtp)
{
await _signaling.EndAsync(call.Id, _lifetime.Token);
Status = "Invitation WebRTC non prise en charge par le mode Linux LAN.";
return;
}
if (_call != null) throw new InvalidOperationException("Invitation concurrente inattendue.");
if (call.RelayOnly) throw new InvalidOperationException("Le mode LAN ne peut pas masquer les adresses IP.");
_call = call;
Busy = true;
Incoming = true;
Status = $"{call.Caller} propose un appel SRTP dans {call.RoomName}. Acceptez ou refusez.";
await Task.CompletedTask;
});
private void OnAccepted(CallAccepted accepted) => Queue(async () =>
{
if (_call?.Id != accepted.Session.Id) return;
if (_media != null) throw new InvalidOperationException("Acceptation repetee.");
if (accepted.Session.Transport != CallTransports.LanSrtp || accepted.Session.RelayOnly ||
(Incoming && !_accepting))
throw new InvalidOperationException("Acceptation ou politique de transport inattendue.");
_call = accepted.Session;
Incoming = false;
_accepting = false;
_callLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
_media = _createMedia(_activeAddress!, _activePort);
LocalVideo = _media.LocalVideo;
RemoteVideo = _media.RemoteVideo;
Status = "Connexion SRTP : attente du pair (30 secondes maximum).";
await _signaling.SendAsync(_call.Id, new("rtp-endpoint", Endpoint: _media.Endpoint), _callLifetime.Token);
_ = MonitorAsync(_call.Id, _media, _callLifetime.Token);
});
private void OnSignal(Guid id, CallSignal signal) => Queue(async () =>
{
if (_call?.Id != id) return;
if (_media == null || _callLifetime == null || signal.Kind != "rtp-endpoint" ||
signal.Endpoint?.IsValid() != true || _endpointReceived)
throw new InvalidOperationException("Destination SRTP manquante, invalide ou repetee.");
_endpointReceived = true;
await _media.StartSendingAsync(signal.Endpoint, _activeCamera!, _activeMicrophone!, _callLifetime.Token);
Status = "Emission SRTP demarree ; verification de la reception audio-video.";
});
private void OnEnded(CallEnded ended) => QueueStop(ended.CallId, async () =>
{
if (_call?.Id == ended.CallId)
await StopAsync("Appel termine : " + ended.Reason, false);
});
private void OnInterrupted() => QueueStop(null,
() => StopAsync("Connexion SignalR interrompue ; medias arretes.", false));
private void QueueStop(Guid? id, Func<Task> action) => Dispatcher.UIThread.Post(async () =>
{
if (id == null || _call?.Id == id) _callLifetime?.Cancel();
await RunAsync(action);
});
private async Task MonitorAsync(Guid id, ILanCallMedia media, CancellationToken ct)
{
var start = Stopwatch.StartNew();
var received = false;
try
{
while (!ct.IsCancellationRequested)
{
if (media.Completion.IsCompleted) await media.Completion;
if (!received && media.HasRemoteMedia)
{
received = true;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_call?.Id == id) Status = "Reception audio-video SRTP etablie (LAN/VPN).";
});
}
if (!received && start.Elapsed > TimeSpan.FromSeconds(30))
throw new TimeoutException("Aucun media distant : verifiez adresses, port UDP et pare-feu LAN/VPN.");
await Task.Delay(500, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception error)
{
Queue(() => _call?.Id == id && ReferenceEquals(_media, media)
? FailAsync(error) : Task.CompletedTask);
}
}
private void Queue(Func<Task> action) =>
Dispatcher.UIThread.Post(async () => await RunAsync(action));
private async Task RunAsync(Func<Task> action)
{
await _events.WaitAsync();
try
{
if (_disposed) return;
Preparing = true;
await action();
}
catch (OperationCanceledException) when (
_lifetime.IsCancellationRequested || _callLifetime?.IsCancellationRequested == true) { }
catch (Exception error) { await FailAsync(error); }
finally
{
Preparing = false;
_events.Release();
}
}
private async Task FailAsync(Exception error)
{
try { await StopAsync("Echec de l'appel : " + error.Message, true); }
catch (Exception cleanupError)
{
Status = $"Echec de l'appel : {error.Message}. Nettoyage/signalisation : {cleanupError.Message}";
}
}
private async Task StopAsync(string status, bool notify)
{
var call = _call;
_call = null;
Incoming = false;
Busy = false;
_accepting = false;
_endpointReceived = false;
_callLifetime?.Cancel();
var media = _media;
_media = null;
try
{
if (media != null) await media.DisposeAsync();
}
finally
{
LocalVideo = null;
RemoteVideo = null;
_callLifetime?.Dispose();
_callLifetime = null;
Status = status;
}
if (notify && call != null)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _signaling.EndAsync(call.Id, timeout.Token);
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_lifetime.Cancel();
await _events.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
_signaling.Invitation -= OnInvitation;
_signaling.Accepted -= OnAccepted;
_signaling.Signal -= OnSignal;
_signaling.Ended -= OnEnded;
_signaling.Interrupted -= OnInterrupted;
try { await StopAsync("Appel arrete.", true); }
finally { _lifetime.Dispose(); }
}
finally { _events.Release(); }
}
}

View file

@ -10,13 +10,14 @@
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat"
xmlns:services="using:PostIt.Services"
x:DataType="vm:ChatViewModel"
>
<Design.DataContext>
<vm:ChatViewModel />
</Design.DataContext>
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto, Auto">
<Grid
Grid.Row="1"
@ -102,9 +103,52 @@
Content="Envoyer" />
</Grid>
<Expander Grid.Row="3" Header="Appel Linux direct (LAN/VPN)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
IsEnabled="{Binding IsConnected}" Margin="0,0,0,8">
<StackPanel DataContext="{Binding LanCall}" x:DataType="vm:LanCallViewModel" Spacing="8">
<TextBlock Text="Deux Desktop Linux sur LAN/VPN. SRTP chiffre les medias ; le pair connait votre IP. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
TextWrapping="Wrap" />
<WrapPanel IsEnabled="{Binding !Busy}">
<Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" />
<ComboBox Width="160" ItemsSource="{Binding Addresses}" SelectedItem="{Binding Address}"
PlaceholderText="Adresse locale LAN/VPN" Margin="0,0,8,4" />
<TextBox Width="80" Text="{Binding Port}" PlaceholderText="Port pair" Margin="0,0,8,4" />
<ComboBox Width="190" ItemsSource="{Binding Cameras}" SelectedItem="{Binding Camera}"
PlaceholderText="Camera" Margin="0,0,8,4">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Width="190" ItemsSource="{Binding Microphones}" SelectedItem="{Binding Microphone}"
PlaceholderText="Microphone" Margin="0,0,8,4">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBox Width="170" Text="{Binding Target}" PlaceholderText="Pseudo du pair" Margin="0,0,8,4" />
</WrapPanel>
<CheckBox Content="Je consens a partager mon adresse LAN/VPN avec le pair."
IsChecked="{Binding AllowAddressDisclosure}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Appeler dans la salle active" Command="{Binding InviteCommand}" IsEnabled="{Binding !Busy}" />
<Button Content="Accepter" Command="{Binding AcceptCommand}" IsEnabled="{Binding Incoming}" />
<Button Content="Refuser / Raccrocher" Command="{Binding HangupCommand}" IsEnabled="{Binding Busy}" />
</StackPanel>
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,2*" Height="280" IsVisible="{Binding !!RemoteVideo}">
<ContentControl Content="{Binding LocalVideo}" Margin="0,0,8,0" />
<ContentControl Grid.Column="1" Content="{Binding RemoteVideo}" />
</Grid>
</StackPanel>
</Expander>
<postitControls:StatusBar
x:Name="StatusBar"
Grid.Row="3"
Grid.Row="4"
DataContext="{Binding Status}" />
</Grid>

View file

@ -1,4 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using PostIt.ViewModels.Chat;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
@ -8,4 +10,10 @@ public partial class ChatPage : ContentPage
InitializeComponent();
}
protected override async void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (DataContext is ChatViewModel { LanCall: { } call })
await call.HangupAsync();
}
}