From cc28ee8b29c101186d05ce1289a30404362504ae Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 26 Oct 2026 03:05:23 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + doc/architecture/audio-video-calls.md | 148 ++++++++- .../PostIt.Tests/CallSignalingClientTests.cs | 6 + .../PostIt.Tests/LanCallViewModelTests.cs | 192 +++++++++++ .../PostIt.Tests/LinuxLanCallMediaTests.cs | 113 +++++++ .../PostIt/Services/CallSignalingClient.cs | 8 +- .../PostIt/Services/ILanCallSignaling.cs | 19 ++ .../PostIt/Services/LinuxLanCallMedia.cs | 179 ++++++++++ .../PostIt/Services/LinuxLanCallVideo.cs | 115 +++++++ .../PostIt/ViewModels/Chat/ChatViewModel.cs | 29 +- .../ViewModels/Chat/LanCallViewModel.cs | 311 ++++++++++++++++++ src/PostIt/PostIt/Views/Chat/ChatPage.axaml | 48 ++- .../PostIt/Views/Chat/ChatPage.axaml.cs | 8 + .../Chat/Calls/CallContracts.cs | 36 +- src/Yavsc.Server/Hubs/ChatHub.Calls.cs | 19 +- .../Services/CallSignalValidator.cs | 2 + .../Services/CallSignalingService.cs | 37 ++- .../Settings/AudioVideoCallOptions.cs | 1 + src/test/Yavsc.Server.Tests/CallHubTests.cs | 35 +- .../CallLanSignalingTests.cs | 116 +++++++ 20 files changed, 1394 insertions(+), 29 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/LanCallViewModelTests.cs create mode 100644 src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs create mode 100644 src/PostIt/PostIt/Services/ILanCallSignaling.cs create mode 100644 src/PostIt/PostIt/Services/LinuxLanCallMedia.cs create mode 100644 src/PostIt/PostIt/Services/LinuxLanCallVideo.cs create mode 100644 src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs create mode 100644 src/test/Yavsc.Server.Tests/CallLanSignalingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5093a78f5..10ebb7f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +* [PostIt.Desktop/API] Add opt-in Linux LAN/VPN peer calls through the existing ChatHub, reusing Podcast LibVLC camera/microphone capture and H.264/AAC encoding with direct SRTP instead of HLS. Add consent, embedded local/remote video, hangup/interruption cleanup, private IPv4 validation and per-direction ephemeral keys over HTTPS-only signaling. Reject IP-masking accounts rather than silently bypassing TURN; keep this transport disabled by default and separate from the future Internet/group-call design. Test real bidirectional encrypted audio/video, wrong-key rejection, preview output, signaling and UI lifecycle; physical camera/two-machine rendering remains to validate. * [PostIt/API] Begin Android/Linux call integration on the existing authenticated ChatHub: typed invitation, single-device consent, bounded SDP/ICE exchange and hangup; derive relay policy from account preferences and issue temporary coturn credentials. Attach the shared PostIt signaling client to the chat connection, end calls on disconnect/room departure/privacy activation, and add targeted regression tests. Calls remain disabled by default; native media adapters and Android/Linux end-to-end validation are not implemented yet. * [PostIt] Clarify reuse of the existing ChatHub for call signaling and document the inconclusive Android native WebRTC experiment; discard the isolated laboratory without changing production chat or media behavior. * [PostIt/API] Persist the account-level `HideIpAddressInCalls` preference (default false), expose it through the authenticated profile API and PostIt profile editor, and preserve it when older clients omit it. Prepare future TURN-only calls without enabling media transport yet. diff --git a/doc/architecture/audio-video-calls.md b/doc/architecture/audio-video-calls.md index b6b157cbf..f29d18746 100644 --- a/doc/architecture/audio-video-calls.md +++ b/doc/architecture/audio-video-calls.md @@ -2,14 +2,14 @@ ## Statut et périmètre -Ce document décrit l'architecture cible et l'implémentation commencée : -les appels ne sont pas encore disponibles. La préférence de confidentialité -est persistée et éditable dans le profil. Les premiers contrats de -signalisation sont intégrés au chat, désactivés par défaut ; aucun transport -média natif n'est encore raccordé. +Ce document décrit l'architecture cible et l'implémentation commencée. +La préférence de confidentialité est persistée et éditable dans le profil. +La signalisation est intégrée au chat, désactivée par défaut. Un premier +transport natif **Desktop Linux vers Desktop Linux sur LAN/VPN** est +raccordé : capture LibVLC existante et SRTP direct, sans libwebrtc ni JNI +Android. Il ne réalise pas encore la cible Internet, TURN ou groupe. Il complète la [topologie de PostIt](postit.md) et les [mécanismes de notification existants](notifications.md). -œ La cible couvre : - les appels audio-vidéo bidirectionnels entre deux utilisateurs ; @@ -18,8 +18,131 @@ La cible couvre : - les appels de groupe ; - les clients Browser, Android et Desktop. +## Première implémentation Desktop Linux : SRTP sur LAN/VPN + +Cette tranche réutilise la capture caméra/microphone et les encodeurs des +Podcasts, mais **pas leur segmentation HLS ni leurs envois de fichiers** : + +```text +PostIt Linux A ----- ChatHub authentifié / HTTPS ----- PostIt Linux B + invitation, consentement, + adresses et clés, fin + +PostIt Linux A ===== H.264/AAC, MPEG-TS sur SRTP/UDP ===== PostIt Linux B + dans les deux sens +``` + +L'implémentation est dans +[LinuxLanCallMedia](../../src/PostIt/PostIt/Services/LinuxLanCallMedia.cs), +[LinuxLanCallVideo](../../src/PostIt/PostIt/Services/LinuxLanCallVideo.cs) et +[LanCallViewModel](../../src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs). +Les contrôles et les deux surfaces vidéo sont intégrés à la page du chat. +Il n'y a pas de service de salles supplémentaire, d'enregistrement, +de fichier média temporaire ou de moteur WebRTC Android ajouté. + +### Activer et essayer + +1. Sur le serveur, activer explicitement les deux options : + + ```json + { + "AudioVideoCalls": { + "Enabled": true, + "EnableLanCalls": true + } + } + ``` + + Les deux valeurs sont `false` par défaut. Les paramètres TURN ne sont + pas nécessaires pour ce mode. Utiliser le ChatHub en **HTTPS** ; + invitation, acceptation et échange de clés LAN sont refusés en HTTP. + Derrière un proxy TLS, configurer correctement le schéma HTTPS transmis + à ASP.NET Core, uniquement depuis les proxys de confiance. +2. Utiliser deux Desktop Linux avec LibVLC et ses modules V4L2, PulseAudio, + x264, AAC et RTP/SRTP installés. La version Debian 13 a été utilisée + pour les essais natifs. Les deux comptes doivent être présents dans + la même salle existante et ne pas se bloquer mutuellement. +3. Dans « Appel Linux direct (LAN/VPN) », actualiser les périphériques, + sélectionner la caméra, le microphone et une IPv4 privée locale + accessible depuis le pair. Les adresses RFC1918 et de bouclage sont + acceptées ; pas de DNS, IPv6 ou adresse publique. Le bouclage sert aux + essais sur un même hôte, pas à joindre un second poste. +4. Choisir un port UDP **pair entre 1024 et 65534**, par défaut 5004. + Autoriser l'entrée UDP sur ce port depuis le pair dans le pare-feu. + RTP/RTCP partagent ce port. Deux clients sur la même adresse doivent + utiliser des ports distincts. Aucun pare-feu n'est modifié automatiquement. +5. Saisir le pseudo exact du destinataire, confirmer la divulgation de + l'adresse LAN/VPN, puis appeler. Le destinataire choisit ses propres + périphériques et son adresse, confirme et accepte explicitement. + Aucun accès à la caméra/microphone ne commence avant l'acceptation ; + l'émission attend également la destination validée du pair. +6. Utiliser un **casque** : il n'y a pas d'annulation d'écho acoustique. + « Refuser / Raccrocher », la sortie de page et la perte de SignalR + arrêtent et libèrent les médias. Il n'y a pas de reprise automatique + de capture après reconnexion. + +### Confidentialité et limites + +- Une clé SRTP aléatoire de 128 bits et un sel de 112 bits sont générés + par appel et par direction avec le générateur cryptographique système. + Seuls les deux appareils ayant accepté échangent un endpoint chacun + via SignalR authentifié. Les représentations diagnostiques masquent + adresse, clé et sel. +- Le chiffrement SRTP est fourni par LibVLC ; les clés passent par + le serveur de signalisation, qui **peut donc les connaître**. Ce n'est + pas un chiffrement de bout en bout à l'insu du serveur. +- Si l'un des comptes impose `HideIpAddressInCalls`, le mode LAN est + **refusé**, y compris si la préférence change avant l'acceptation. + Il n'y a aucun repli direct contournant cette préférence. +- Pas d'ICE, de STUN, de TURN, de traversée NAT automatique, de groupe, + d'interopérabilité Browser/Android, de négociation de codecs ni + d'adaptation de débit. Ce mode exige un LAN/VPN routable entre les pairs. +- H.264 à 30 images/s, cible 1 200 kbit/s ; AAC mono 48 kHz, 96 kbit/s. + Ces réglages ne constituent pas une garantie de latence ni de débit. + Le retour local n'émet pas le son du microphone. +- L'absence de première réception audio **et** vidéo provoque l'arrêt + après 30 secondes ; les erreurs natives sont affichées et terminent + l'appel. Après réception initiale, les compteurs LibVLC ne permettent + pas ici de diagnostiquer précisément une panne média silencieuse : + l'utilisateur peut raccrocher, et la perte de SignalR arrête toujours + la capture. Pas encore de mesure de qualité ou de latence affichée. + +### Validation effectuée et restante + +Les tests natifs utilisent deux moteurs LibVLC réels, un fichier audio-vidéo +synthétique fourni explicitement et de vrais sockets UDP de bouclage : + +- décodage bidirectionnel de trames vidéo et d'échantillons audio PCM ; +- refus de décodage avec une mauvaise clé, sans casser l'autre direction ; +- même échange avec la branche de prévisualisation locale activée + (sortie factice dans ce test sans fenêtre). + +Les tests de signalisation couvrent consentement, sélection d'un seul +appareil, confidentialité, HTTPS, validation des endpoints et arrêt. +Les tests Avalonia sans fenêtre couvrent l'ordre invitation/acceptation/ +capture, le refus, les événements tardifs, l'interruption, les erreurs média +et l'annulation d'un démarrage natif en attente. + +Pour exécuter les tests média, définir `POSTIT_TEST_MEDIA` avec le chemin +d'un MP4 H.264/AAC de test (640 × 480), puis : + +```sh +dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ + --filter-class '*LinuxLanCallMediaTests' +``` + +Sans cette entrée explicite, ces tests sont ignorés, pas remplacés par un +succès fictif. Les tests de contrôle s'exécutent sans caméra. +**La caméra physique, le rendu natif visible dans deux applications, le +son audible et un appel entre deux machines restent à valider** : aucune +caméra n'était disponible sur l'hôte de développement. Les essais de +bouclage ne prouvent ni ces points ni une latence conversationnelle. + ## Choix des protocoles +La table suivante demeure la **cible multi-plateforme initiale**, distincte +du premier transport Linux LAN/VPN décrit ci-dessus. + | Besoin | Choix | Rôle | |---|---|---| | Médias temps réel | WebRTC | Audio-vidéo bidirectionnel à faible latence, adaptation au réseau | @@ -298,14 +421,13 @@ propose pas encore de bouton d'appel ; une éventuelle invitation est signalée comme non utilisable faute de moteur natif, sans acceptation automatique. -### Travail natif restant avant activation +### Travail restant pour la cible Internet multi-plateforme -- Android : intégrer un adaptateur libwebrtc dans l'application existante, - isoler le problème JNI observé, vérifier les versions Java/natives et - conserver les diagnostics fatals utiles, sans journaliser SDP ou secrets. -- Desktop Linux : intégrer un moteur WebRTC natif dans PostIt.Desktop - avec capture réelle et rendu Avalonia ; un processus Python de test - GStreamer ne vaut pas cette intégration. +- Android : l'approche Java/JNI libwebrtc a été écartée à la demande du + projet ; aucun adaptateur expérimental n'est conservé. Le transport + compatible avec la capture Podcast et la cible Internet reste à choisir. +- Desktop Linux : le transport SRTP LAN est raccordé, mais ne remplace pas + les validations matérielles ni une solution de connectivité Internet. - Côté partagé : relier consentement, états réels du moteur, mise en sourdine, arrêt et libération des ressources à la signalisation. - Valider un appel Android ↔ Desktop Linux avec caméra avant et microphone diff --git a/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs b/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs index 60a1c784b..c3f7945cb 100644 --- a/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs +++ b/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs @@ -15,6 +15,7 @@ public sealed class CallSignalingClientTests var id = Guid.NewGuid(); var ct = TestContext.Current.CancellationToken; await Assert.ThrowsAsync(() => client.InviteAsync("General", "bob", ct)); + await Assert.ThrowsAsync(() => client.InviteLanAsync("General", "bob", ct)); await Assert.ThrowsAsync(() => client.AcceptAsync(id, ct)); await Assert.ThrowsAsync(() => client.EndAsync(id, ct)); await Assert.ThrowsAsync(() => 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(JsonSerializer.Serialize(signal, options), options)); + var lan = call with { RelayOnly = false, Transport = CallTransports.LanSrtp }; + Assert.Equal(lan, JsonSerializer.Deserialize(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(JsonSerializer.Serialize(endpoint, options), options)); } } diff --git a/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs b/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs new file mode 100644 index 000000000..fd31d8c50 --- /dev/null +++ b/src/PostIt/PostIt.Tests/LanCallViewModelTests.cs @@ -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 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 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? Invitation; + public event Action? Accepted; + public event Action? Ended; + public event Action? Signal; + public event Action? Interrupted; + public CallSession Call { get; } = Session(); + public List Sent { get; } = []; + public List EndedCalls { get; } = []; + public int Invites { get; private set; } + public Task 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(); + } +} diff --git a/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs b/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs new file mode 100644 index 000000000..f98d479d7 --- /dev/null +++ b/src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs @@ -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); + } + } +} diff --git a/src/PostIt/PostIt/Services/CallSignalingClient.cs b/src/PostIt/PostIt/Services/CallSignalingClient.cs index f4c5ffd91..7ad171d3b 100644 --- a/src/PostIt/PostIt/Services/CallSignalingClient.cs +++ b/src/PostIt/PostIt/Services/CallSignalingClient.cs @@ -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(CallHubMethods.Invite, room, user, ct); } + public Task InviteLanAsync(string room, string user, CancellationToken ct = default) + { + EnsureConnected(); + return _connection.InvokeAsync(CallHubMethods.InviteLan, room, user, ct); + } + public Task AcceptAsync(Guid callId, CancellationToken ct = default) { EnsureConnected(); diff --git a/src/PostIt/PostIt/Services/ILanCallSignaling.cs b/src/PostIt/PostIt/Services/ILanCallSignaling.cs new file mode 100644 index 000000000..9d592299d --- /dev/null +++ b/src/PostIt/PostIt/Services/ILanCallSignaling.cs @@ -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? Invitation; + event Action? Accepted; + event Action? Ended; + event Action? Signal; + event Action? Interrupted; + Task 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); +} diff --git a/src/PostIt/PostIt/Services/LinuxLanCallMedia.cs b/src/PostIt/PostIt/Services/LinuxLanCallMedia.cs new file mode 100644 index 000000000..bced26e9b --- /dev/null +++ b/src/PostIt/PostIt/Services/LinuxLanCallMedia.cs @@ -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 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(); } + } +} diff --git a/src/PostIt/PostIt/Services/LinuxLanCallVideo.cs b/src/PostIt/PostIt/Services/LinuxLanCallVideo.cs new file mode 100644 index 000000000..3eb040941 --- /dev/null +++ b/src/PostIt/PostIt/Services/LinuxLanCallVideo.cs @@ -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(); + } + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs index bb13e3c0a..35dc445cc 100644 --- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs @@ -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}.")); diff --git a/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs new file mode 100644 index 000000000..2f9cda32d --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs @@ -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 _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 Addresses { get; } = []; + public ObservableCollection Cameras { get; } = []; + public ObservableCollection 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? 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 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 action) => + Dispatcher.UIThread.Post(async () => await RunAsync(action)); + + private async Task RunAsync(Func 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(); } + } +} diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml index 4749736a7..d7a506fce 100644 --- a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml +++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml @@ -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" > - + + + + + +