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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace Yavsc.Abstract.Chat.Calls;
|
|||
public static class CallHubMethods
|
||||
{
|
||||
public const string Invite = "InviteCall";
|
||||
public const string InviteLan = "InviteLanCall";
|
||||
public const string Accept = "AcceptCall";
|
||||
public const string End = "EndCall";
|
||||
public const string Signal = "SendCallSignal";
|
||||
|
|
@ -16,7 +17,13 @@ public static class CallHubMethods
|
|||
|
||||
public sealed record CallSession(
|
||||
Guid Id, string RoomName, string Caller, string Callee, bool RelayOnly,
|
||||
DateTimeOffset ExpiresAt);
|
||||
DateTimeOffset ExpiresAt, string Transport = CallTransports.WebRtc);
|
||||
|
||||
public static class CallTransports
|
||||
{
|
||||
public const string WebRtc = "webrtc";
|
||||
public const string LanSrtp = "lan-srtp";
|
||||
}
|
||||
|
||||
public sealed record CallIceServer(string[] Urls, string? Username = null, string? Credential = null);
|
||||
|
||||
|
|
@ -26,4 +33,29 @@ public sealed record CallEnded(Guid CallId, string Reason);
|
|||
|
||||
public sealed record CallSignal(
|
||||
string Kind, string? Sdp = null, string? Candidate = null,
|
||||
string? SdpMid = null, int? SdpMLineIndex = null);
|
||||
string? SdpMid = null, int? SdpMLineIndex = null, LanCallEndpoint? Endpoint = null);
|
||||
|
||||
public sealed record LanCallEndpoint(string Address, int Port, string Key, string Salt)
|
||||
{
|
||||
public override string ToString() => "SRTP endpoint (credentials redacted)";
|
||||
|
||||
public bool IsValid() =>
|
||||
LanCallNetwork.IsPrivateIPv4(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 &&
|
||||
Key is { Length: 32 } && Key.All(Uri.IsHexDigit) &&
|
||||
Salt is { Length: 28 } && Salt.All(Uri.IsHexDigit);
|
||||
}
|
||||
|
||||
public static class LanCallNetwork
|
||||
{
|
||||
public static bool IsPrivateIPv4(string address)
|
||||
{
|
||||
if (!System.Net.IPAddress.TryParse(address, out var ip) ||
|
||||
ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork ||
|
||||
ip.ToString() != address)
|
||||
return false;
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes[0] == 10 || bytes[0] == 127 ||
|
||||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
|
||||
(bytes[0] == 192 && bytes[1] == 168);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,30 @@ namespace Yavsc.Server.Hubs;
|
|||
|
||||
public partial class ChatHub
|
||||
{
|
||||
public async Task<CallSession> InviteCall(string roomName, string callee)
|
||||
public Task<CallSession> InviteCall(string roomName, string callee) =>
|
||||
InviteCoreAsync(roomName, callee, CallTransports.WebRtc);
|
||||
|
||||
public Task<CallSession> InviteLanCall(string roomName, string callee) =>
|
||||
InviteCoreAsync(roomName, callee, CallTransports.LanSrtp);
|
||||
|
||||
private async Task<CallSession> InviteCoreAsync(string roomName, string callee, string transport)
|
||||
{
|
||||
var caller = CallUser();
|
||||
RequireSecureLanControl(transport);
|
||||
if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) ||
|
||||
string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee))
|
||||
throw new HubException("Invalid call participant or room.");
|
||||
RequireCallPresence(roomName, caller, callee);
|
||||
var relayOnly = await RequiresRelayAsync(caller, callee);
|
||||
return await _calls.InviteAsync(roomName, caller, callee, Context.ConnectionId,
|
||||
_cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly);
|
||||
_cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly, transport);
|
||||
}
|
||||
|
||||
public async Task AcceptCall(Guid callId)
|
||||
{
|
||||
var user = CallUser();
|
||||
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
|
||||
RequireSecureLanControl(session.Transport);
|
||||
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
|
||||
await _calls.AcceptAsync(callId, user, Context.ConnectionId,
|
||||
await RequiresRelayAsync(session.Caller, session.Callee));
|
||||
|
|
@ -36,6 +44,7 @@ public partial class ChatHub
|
|||
{
|
||||
var user = CallUser();
|
||||
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
|
||||
RequireSecureLanControl(session.Transport);
|
||||
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
|
||||
if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee))
|
||||
{
|
||||
|
|
@ -53,6 +62,12 @@ public partial class ChatHub
|
|||
return user;
|
||||
}
|
||||
|
||||
private void RequireSecureLanControl(string transport)
|
||||
{
|
||||
if (transport == CallTransports.LanSrtp && Context.GetHttpContext()?.Request.IsHttps != true)
|
||||
throw new HubException("LAN calls require HTTPS signaling to protect SRTP keys.");
|
||||
}
|
||||
|
||||
private void RequireCallPresence(string room, string caller, string callee)
|
||||
{
|
||||
if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) ||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public static class CallSignalValidator
|
|||
{
|
||||
if (signal is null)
|
||||
throw new HubException("A call signal is required.");
|
||||
if (signal.Endpoint != null)
|
||||
throw new HubException("WebRTC signaling cannot carry a LAN endpoint.");
|
||||
if (signal.Kind is "offer" or "answer")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signal.Sdp) || Encoding.UTF8.GetByteCount(signal.Sdp) > 24000 ||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public sealed class CallSignalingService(
|
|||
public bool Offered { get; set; }
|
||||
public bool Answered { get; set; }
|
||||
public int CandidateCount { get; set; }
|
||||
public HashSet<string> EndpointConnections { get; } = [];
|
||||
public string[] Connections => [CallerConnection, .. CalleeConnections];
|
||||
}
|
||||
|
||||
|
|
@ -36,13 +37,13 @@ public sealed class CallSignalingService(
|
|||
|
||||
public async Task<CallSession> InviteAsync(
|
||||
string room, string caller, string callee, string callerConnection,
|
||||
string[] calleeConnections, bool relayOnly)
|
||||
string[] calleeConnections, bool relayOnly, string transport = CallTransports.WebRtc)
|
||||
{
|
||||
EnsureEnabled();
|
||||
if (caller == callee || calleeConnections.Length == 0 ||
|
||||
calleeConnections.Contains(callerConnection))
|
||||
throw new HubException("Call participants are unavailable.");
|
||||
ValidateIceConfiguration(relayOnly);
|
||||
ValidateTransport(transport, relayOnly);
|
||||
await _gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
|
|
@ -52,7 +53,7 @@ public sealed class CallSignalingService(
|
|||
c.Session.Caller == callee || c.Session.Callee == callee))
|
||||
throw new HubException("A participant is already in a call.");
|
||||
var session = new CallSession(Guid.NewGuid(), room, caller, callee, relayOnly,
|
||||
clock.GetUtcNow().AddSeconds(45));
|
||||
clock.GetUtcNow().AddSeconds(45), transport);
|
||||
var call = new PendingCall(session, callerConnection, calleeConnections);
|
||||
_calls.Add(session.Id, call);
|
||||
try
|
||||
|
|
@ -83,7 +84,7 @@ public sealed class CallSignalingService(
|
|||
if (call.AcceptedConnection != null)
|
||||
throw new HubException("This call was already accepted.");
|
||||
var requireRelay = call.Session.RelayOnly || relayOnly;
|
||||
ValidateIceConfiguration(requireRelay);
|
||||
ValidateTransport(call.Session.Transport, requireRelay);
|
||||
call.AcceptedConnection = connection;
|
||||
call.Session = call.Session with
|
||||
{
|
||||
|
|
@ -92,7 +93,8 @@ public sealed class CallSignalingService(
|
|||
};
|
||||
var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray();
|
||||
call.CalleeConnections.IntersectWith([connection]);
|
||||
var accepted = new CallAccepted(call.Session, CreateIceServers(call.Session));
|
||||
var accepted = new CallAccepted(call.Session, call.Session.Transport == CallTransports.LanSrtp
|
||||
? [] : CreateIceServers(call.Session));
|
||||
try
|
||||
{
|
||||
if (otherDevices.Length > 0)
|
||||
|
|
@ -122,6 +124,18 @@ public sealed class CallSignalingService(
|
|||
var callee = call.AcceptedConnection == connection && call.Session.Callee == user;
|
||||
if ((!caller && !callee) || call.AcceptedConnection == null)
|
||||
throw new HubException("Media signaling requires acceptance on this device.");
|
||||
if (call.Session.Transport == CallTransports.LanSrtp)
|
||||
{
|
||||
ValidateTransport(call.Session.Transport, call.Session.RelayOnly);
|
||||
if (signal is null || signal.Kind != "rtp-endpoint" || signal.Endpoint?.IsValid() != true ||
|
||||
signal.Sdp != null || signal.Candidate != null || signal.SdpMid != null ||
|
||||
signal.SdpMLineIndex != null || call.EndpointConnections.Contains(connection))
|
||||
throw new HubException("A LAN call requires one valid SRTP endpoint per accepted device.");
|
||||
var endpointTarget = caller ? call.AcceptedConnection : call.CallerConnection;
|
||||
await hub.Clients.Client(endpointTarget).SendAsync(CallHubMethods.SignalReceived, id, signal);
|
||||
call.EndpointConnections.Add(connection);
|
||||
return;
|
||||
}
|
||||
CallSignalValidator.Validate(signal, call.Session.RelayOnly);
|
||||
switch (signal.Kind)
|
||||
{
|
||||
|
|
@ -252,6 +266,19 @@ public sealed class CallSignalingService(
|
|||
throw new HubException("ICE/TURN configuration is unavailable. No direct fallback is allowed.");
|
||||
}
|
||||
|
||||
private void ValidateTransport(string transport, bool relayOnly)
|
||||
{
|
||||
if (transport == CallTransports.LanSrtp)
|
||||
{
|
||||
if (!_options.EnableLanCalls || relayOnly)
|
||||
throw new HubException("LAN calls must be enabled and cannot hide participant IP addresses.");
|
||||
}
|
||||
else if (transport == CallTransports.WebRtc)
|
||||
ValidateIceConfiguration(relayOnly);
|
||||
else
|
||||
throw new HubException("Unsupported call transport.");
|
||||
}
|
||||
|
||||
private static bool ValidUrl(string url, params string[] schemes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url) || url.Length > 512 || url.Any(char.IsWhiteSpace))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public sealed class AudioVideoCallOptions
|
|||
public const string SectionName = "AudioVideoCalls";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public bool EnableLanCalls { get; set; }
|
||||
public string[] StunUrls { get; set; } = [];
|
||||
public string[] TurnUrls { get; set; } = [];
|
||||
public string? TurnSharedSecret { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Connections.Features;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -32,7 +34,8 @@ public sealed class CallHubTests
|
|||
_services = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider();
|
||||
Calls = new(Delivery, Options.Create(new AudioVideoCallOptions
|
||||
{
|
||||
Enabled = true, TurnUrls = ["turn:turn.example.test:3478"], TurnSharedSecret = "test-only"
|
||||
Enabled = true, EnableLanCalls = true,
|
||||
TurnUrls = ["turn:turn.example.test:3478"], TurnSharedSecret = "test-only"
|
||||
}), TimeProvider.System, NullLogger<CallSignalingService>.Instance);
|
||||
Db.Users.AddRange(
|
||||
new ApplicationUser { Id = "alice-id", UserName = "alice", Email = "alice@example.test" },
|
||||
|
|
@ -55,6 +58,36 @@ public sealed class CallHubTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lan_requires_https_at_invitation_acceptance_and_key_exchange()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var caller = f.Hub("alice", "alice-1");
|
||||
using var callee = f.Hub("bob", "bob-1");
|
||||
await Assert.ThrowsAsync<HubException>(() => caller.InviteLanCall("General", "bob"));
|
||||
Assert.Empty(f.Delivery.Messages);
|
||||
var http = new DefaultHttpContext();
|
||||
http.Request.Scheme = "https";
|
||||
caller.Context.Features.Set<IHttpContextFeature>(new HttpFeature(http));
|
||||
var call = await caller.InviteLanCall("General", "bob");
|
||||
await Assert.ThrowsAsync<HubException>(() => callee.AcceptCall(call.Id));
|
||||
callee.Context.Features.Set<IHttpContextFeature>(new HttpFeature(http));
|
||||
await callee.AcceptCall(call.Id);
|
||||
var endpoint = new CallSignal("rtp-endpoint",
|
||||
Endpoint: new("192.168.1.2", 5004, new string('a', 32), new string('b', 28)));
|
||||
http.Request.Scheme = "http";
|
||||
await Assert.ThrowsAsync<HubException>(() => caller.SendCallSignal(call.Id, endpoint));
|
||||
Assert.DoesNotContain(f.Delivery.Messages, m => m.Method == CallHubMethods.SignalReceived);
|
||||
http.Request.Scheme = "https";
|
||||
await caller.SendCallSignal(call.Id, endpoint);
|
||||
Assert.Single(f.Delivery.Messages, m => m.Method == CallHubMethods.SignalReceived);
|
||||
}
|
||||
|
||||
private sealed class HttpFeature(HttpContext context) : IHttpContextFeature
|
||||
{
|
||||
public HttpContext? HttpContext { get; set; } = context;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitation_requires_existing_accounts_presence_and_the_authenticated_connection()
|
||||
{
|
||||
|
|
|
|||
116
src/test/Yavsc.Server.Tests/CallLanSignalingTests.cs
Normal file
116
src/test/Yavsc.Server.Tests/CallLanSignalingTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Abstract.Chat.Calls;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Settings;
|
||||
|
||||
namespace Yavsc.Server.Tests;
|
||||
|
||||
public sealed class CallLanSignalingTests
|
||||
{
|
||||
private static LanCallEndpoint Endpoint(string address = "192.168.1.2", int port = 5004) =>
|
||||
new(address, port, new string('a', 32), new string('b', 28));
|
||||
|
||||
private static CallSignalingService Service(RecordingCallHub hub, bool enabled = true) =>
|
||||
new(hub, Options.Create(new AudioVideoCallOptions { Enabled = true, EnableLanCalls = enabled }),
|
||||
TimeProvider.System, NullLogger<CallSignalingService>.Instance);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(true, true)]
|
||||
public async Task Disabled_lan_or_ip_masking_prevents_invitation(bool enabled, bool relay)
|
||||
{
|
||||
var hub = new RecordingCallHub();
|
||||
using var service = Service(hub, enabled);
|
||||
await Assert.ThrowsAsync<HubException>(() =>
|
||||
service.InviteAsync("General", "alice", "bob", "a", ["b"], relay, CallTransports.LanSrtp));
|
||||
Assert.Empty(hub.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ip_masking_activated_before_acceptance_blocks_the_lan_call()
|
||||
{
|
||||
var hub = new RecordingCallHub();
|
||||
using var service = Service(hub);
|
||||
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b"], false, CallTransports.LanSrtp);
|
||||
await Assert.ThrowsAsync<HubException>(() => service.AcceptAsync(call.Id, "bob", "b", true));
|
||||
Assert.DoesNotContain(hub.Messages, m => m.Method == CallHubMethods.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Endpoints_are_exchanged_only_once_between_consented_devices_without_ice_credentials()
|
||||
{
|
||||
var hub = new RecordingCallHub();
|
||||
using var service = Service(hub);
|
||||
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b", "other"], false, CallTransports.LanSrtp);
|
||||
Assert.Equal(CallTransports.LanSrtp, call.Transport);
|
||||
var first = new CallSignal("rtp-endpoint", Endpoint: Endpoint());
|
||||
var second = new CallSignal("rtp-endpoint", Endpoint: Endpoint("10.0.0.2"));
|
||||
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", first));
|
||||
await service.AcceptAsync(call.Id, "bob", "b", false);
|
||||
Assert.Empty(Assert.IsType<CallAccepted>(hub.Messages.Last().Arguments[0]).IceServers);
|
||||
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "bob", "other", second));
|
||||
await service.SignalAsync(call.Id, "alice", "a", first);
|
||||
await service.SignalAsync(call.Id, "bob", "b", second);
|
||||
var sent = hub.Messages.Where(m => m.Method == CallHubMethods.SignalReceived).ToArray();
|
||||
Assert.Equal(2, sent.Length);
|
||||
Assert.Equal(["b"], sent[0].Targets);
|
||||
Assert.Equal(["a"], sent[1].Targets);
|
||||
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", first));
|
||||
await service.EndAsync(call.Id, "alice", "a");
|
||||
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "bob", "b", second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lan_transport_rejects_sdp_candidates_and_mixed_endpoint_payloads()
|
||||
{
|
||||
var hub = new RecordingCallHub();
|
||||
using var service = Service(hub);
|
||||
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b"], false, CallTransports.LanSrtp);
|
||||
await service.AcceptAsync(call.Id, "bob", "b", false);
|
||||
CallSignal[] invalid =
|
||||
[
|
||||
new("offer", "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"),
|
||||
new("candidate", Candidate: "candidate:1 1 udp 100 192.168.1.1 5004 typ host"),
|
||||
new("rtp-endpoint"), new("rtp-endpoint", Sdp: "injected", Endpoint: Endpoint()),
|
||||
new("rtp-endpoint", Endpoint: Endpoint() with { Key = new string('x', 32) }),
|
||||
new("rtp-endpoint", Endpoint: Endpoint() with { Salt = "short" }),
|
||||
new("rtp-endpoint", Endpoint: Endpoint("8.8.8.8")),
|
||||
new("rtp-endpoint", Endpoint: Endpoint(port: 5005))
|
||||
];
|
||||
foreach (var signal in invalid)
|
||||
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", signal));
|
||||
Assert.DoesNotContain(hub.Messages, m => m.Method == CallHubMethods.SignalReceived);
|
||||
Assert.Throws<HubException>(() => CallSignalValidator.Validate(
|
||||
new("offer", "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n", Endpoint: Endpoint()), false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("10.1.2.3", true)]
|
||||
[InlineData("172.16.0.1", true)]
|
||||
[InlineData("172.31.255.254", true)]
|
||||
[InlineData("192.168.1.2", true)]
|
||||
[InlineData("127.0.0.1", true)]
|
||||
[InlineData("172.32.0.1", false)]
|
||||
[InlineData("169.254.169.254", false)]
|
||||
[InlineData("8.8.8.8", false)]
|
||||
[InlineData("224.0.0.1", false)]
|
||||
[InlineData("0.0.0.0", false)]
|
||||
[InlineData("::1", false)]
|
||||
[InlineData("localhost", false)]
|
||||
[InlineData("127.1", false)]
|
||||
[InlineData("192.168.1.2,port=80", false)]
|
||||
public void Only_canonical_private_or_loopback_ipv4_endpoints_are_allowed(string address, bool expected) =>
|
||||
Assert.Equal(expected, Endpoint(address).IsValid());
|
||||
|
||||
[Fact]
|
||||
public void Credentials_are_not_exposed_by_record_diagnostics()
|
||||
{
|
||||
var endpoint = Endpoint();
|
||||
var diagnostic = new CallSignal("rtp-endpoint", Endpoint: endpoint).ToString();
|
||||
Assert.DoesNotContain(endpoint.Key, diagnostic);
|
||||
Assert.DoesNotContain(endpoint.Salt, diagnostic);
|
||||
Assert.DoesNotContain(endpoint.Address, diagnostic);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue