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:
parent
5e48831d6d
commit
cc28ee8b29
20 changed files with 1394 additions and 29 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
src/PostIt/PostIt.Tests/LanCallViewModelTests.cs
Normal file
192
src/PostIt/PostIt.Tests/LanCallViewModelTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
113
src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
Normal file
113
src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
19
src/PostIt/PostIt/Services/ILanCallSignaling.cs
Normal file
19
src/PostIt/PostIt/Services/ILanCallSignaling.cs
Normal 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);
|
||||
}
|
||||
179
src/PostIt/PostIt/Services/LinuxLanCallMedia.cs
Normal file
179
src/PostIt/PostIt/Services/LinuxLanCallMedia.cs
Normal 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(); }
|
||||
}
|
||||
}
|
||||
115
src/PostIt/PostIt/Services/LinuxLanCallVideo.cs
Normal file
115
src/PostIt/PostIt/Services/LinuxLanCallVideo.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}."));
|
||||
|
||||
|
|
|
|||
311
src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs
Normal file
311
src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs
Normal 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(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue