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
|
### 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/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] 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.
|
* [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
|
## Statut et périmètre
|
||||||
|
|
||||||
Ce document décrit l'architecture cible et l'implémentation commencée :
|
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é
|
La préférence de confidentialité est persistée et éditable dans le profil.
|
||||||
est persistée et éditable dans le profil. Les premiers contrats de
|
La signalisation est intégrée au chat, désactivée par défaut. Un premier
|
||||||
signalisation sont intégrés au chat, désactivés par défaut ; aucun transport
|
transport natif **Desktop Linux vers Desktop Linux sur LAN/VPN** est
|
||||||
média natif n'est encore raccordé.
|
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
|
Il complète la [topologie de PostIt](postit.md) et les
|
||||||
[mécanismes de notification existants](notifications.md).
|
[mécanismes de notification existants](notifications.md).
|
||||||
œ
|
|
||||||
La cible couvre :
|
La cible couvre :
|
||||||
|
|
||||||
- les appels audio-vidéo bidirectionnels entre deux utilisateurs ;
|
- les appels audio-vidéo bidirectionnels entre deux utilisateurs ;
|
||||||
|
|
@ -18,8 +18,131 @@ La cible couvre :
|
||||||
- les appels de groupe ;
|
- les appels de groupe ;
|
||||||
- les clients Browser, Android et Desktop.
|
- 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
|
## 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 |
|
| Besoin | Choix | Rôle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Médias temps réel | WebRTC | Audio-vidéo bidirectionnel à faible latence, adaptation au réseau |
|
| 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
|
signalée comme non utilisable faute de moteur natif, sans acceptation
|
||||||
automatique.
|
automatique.
|
||||||
|
|
||||||
### Travail natif restant avant activation
|
### Travail restant pour la cible Internet multi-plateforme
|
||||||
|
|
||||||
- Android : intégrer un adaptateur libwebrtc dans l'application existante,
|
- Android : l'approche Java/JNI libwebrtc a été écartée à la demande du
|
||||||
isoler le problème JNI observé, vérifier les versions Java/natives et
|
projet ; aucun adaptateur expérimental n'est conservé. Le transport
|
||||||
conserver les diagnostics fatals utiles, sans journaliser SDP ou secrets.
|
compatible avec la capture Podcast et la cible Internet reste à choisir.
|
||||||
- Desktop Linux : intégrer un moteur WebRTC natif dans PostIt.Desktop
|
- Desktop Linux : le transport SRTP LAN est raccordé, mais ne remplace pas
|
||||||
avec capture réelle et rendu Avalonia ; un processus Python de test
|
les validations matérielles ni une solution de connectivité Internet.
|
||||||
GStreamer ne vaut pas cette intégration.
|
|
||||||
- Côté partagé : relier consentement, états réels du moteur, mise en
|
- Côté partagé : relier consentement, états réels du moteur, mise en
|
||||||
sourdine, arrêt et libération des ressources à la signalisation.
|
sourdine, arrêt et libération des ressources à la signalisation.
|
||||||
- Valider un appel Android ↔ Desktop Linux avec caméra avant et microphone
|
- 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 id = Guid.NewGuid();
|
||||||
var ct = TestContext.Current.CancellationToken;
|
var ct = TestContext.Current.CancellationToken;
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
|
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.AcceptAsync(id, ct));
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
|
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(id, new("offer", "sdp"), 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",
|
var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay",
|
||||||
SdpMid: "video", SdpMLineIndex: 1);
|
SdpMid: "video", SdpMLineIndex: 1);
|
||||||
Assert.Equal(signal, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(signal, options), options));
|
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;
|
namespace PostIt.Services;
|
||||||
|
|
||||||
public sealed class CallSignalingClient : IDisposable
|
public sealed class CallSignalingClient : ILanCallSignaling, IDisposable
|
||||||
{
|
{
|
||||||
private readonly HubConnection _connection;
|
private readonly HubConnection _connection;
|
||||||
private readonly IDisposable[] _subscriptions;
|
private readonly IDisposable[] _subscriptions;
|
||||||
|
|
@ -39,6 +39,12 @@ public sealed class CallSignalingClient : IDisposable
|
||||||
return _connection.InvokeAsync<CallSession>(CallHubMethods.Invite, room, user, ct);
|
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)
|
public Task AcceptAsync(Guid callId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
EnsureConnected();
|
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 CallSignalingClient? Calls { get; private set; }
|
||||||
|
|
||||||
|
public bool SupportsLanCalls => OperatingSystem.IsLinux() && !OperatingSystem.IsAndroid();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial LanCallViewModel? LanCall { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string CurrentRoom { get; set; } = "General";
|
public partial string CurrentRoom { get; set; } = "General";
|
||||||
|
|
||||||
|
|
@ -83,6 +88,7 @@ namespace PostIt.ViewModels.Chat
|
||||||
|
|
||||||
partial void OnSelectedRoomChanged(string? value)
|
partial void OnSelectedRoomChanged(string? value)
|
||||||
{
|
{
|
||||||
|
if (LanCall != null) LanCall.Room = value ?? "";
|
||||||
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
|
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
|
||||||
? msgs : _systemMessages;
|
? msgs : _systemMessages;
|
||||||
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
|
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
|
||||||
|
|
@ -132,6 +138,13 @@ namespace PostIt.ViewModels.Chat
|
||||||
|
|
||||||
private async Task DisconnectAsync()
|
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?.Dispose();
|
||||||
Calls = null;
|
Calls = null;
|
||||||
if (_connection != null)
|
if (_connection != null)
|
||||||
|
|
@ -146,7 +159,9 @@ namespace PostIt.ViewModels.Chat
|
||||||
_messagesByRoom.Clear();
|
_messagesByRoom.Clear();
|
||||||
_usersByRoom.Clear();
|
_usersByRoom.Clear();
|
||||||
_systemMessages.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()));
|
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,6 +171,11 @@ namespace PostIt.ViewModels.Chat
|
||||||
{
|
{
|
||||||
if (_connection != null)
|
if (_connection != null)
|
||||||
{
|
{
|
||||||
|
if (LanCall != null)
|
||||||
|
{
|
||||||
|
await LanCall.DisposeAsync();
|
||||||
|
LanCall = null;
|
||||||
|
}
|
||||||
Calls?.Dispose();
|
Calls?.Dispose();
|
||||||
Calls = null;
|
Calls = null;
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|
@ -200,8 +220,11 @@ namespace PostIt.ViewModels.Chat
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
Calls = new CallSignalingClient(_connection);
|
Calls = new CallSignalingClient(_connection);
|
||||||
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
if (SupportsLanCalls)
|
||||||
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; moteur natif non disponible."));
|
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(() =>
|
Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
Log($"Appel terminé : {call.Reason}."));
|
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:postitControls="using:PostIt.Controls"
|
||||||
xmlns:views="using:PostIt.Views.Chat"
|
xmlns:views="using:PostIt.Views.Chat"
|
||||||
xmlns:vm="using:PostIt.ViewModels.Chat"
|
xmlns:vm="using:PostIt.ViewModels.Chat"
|
||||||
|
xmlns:services="using:PostIt.Services"
|
||||||
x:DataType="vm:ChatViewModel"
|
x:DataType="vm:ChatViewModel"
|
||||||
>
|
>
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:ChatViewModel />
|
<vm:ChatViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
|
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto, Auto">
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
|
|
@ -102,9 +103,52 @@
|
||||||
Content="Envoyer" />
|
Content="Envoyer" />
|
||||||
</Grid>
|
</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
|
<postitControls:StatusBar
|
||||||
x:Name="StatusBar"
|
x:Name="StatusBar"
|
||||||
Grid.Row="3"
|
Grid.Row="4"
|
||||||
DataContext="{Binding Status}" />
|
DataContext="{Binding Status}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using PostIt.ViewModels.Chat;
|
||||||
namespace PostIt.Views.Chat;
|
namespace PostIt.Views.Chat;
|
||||||
public partial class ChatPage : ContentPage
|
public partial class ChatPage : ContentPage
|
||||||
{
|
{
|
||||||
|
|
@ -8,4 +10,10 @@ public partial class ChatPage : ContentPage
|
||||||
InitializeComponent();
|
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 static class CallHubMethods
|
||||||
{
|
{
|
||||||
public const string Invite = "InviteCall";
|
public const string Invite = "InviteCall";
|
||||||
|
public const string InviteLan = "InviteLanCall";
|
||||||
public const string Accept = "AcceptCall";
|
public const string Accept = "AcceptCall";
|
||||||
public const string End = "EndCall";
|
public const string End = "EndCall";
|
||||||
public const string Signal = "SendCallSignal";
|
public const string Signal = "SendCallSignal";
|
||||||
|
|
@ -16,7 +17,13 @@ public static class CallHubMethods
|
||||||
|
|
||||||
public sealed record CallSession(
|
public sealed record CallSession(
|
||||||
Guid Id, string RoomName, string Caller, string Callee, bool RelayOnly,
|
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);
|
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(
|
public sealed record CallSignal(
|
||||||
string Kind, string? Sdp = null, string? Candidate = null,
|
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 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();
|
var caller = CallUser();
|
||||||
|
RequireSecureLanControl(transport);
|
||||||
if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) ||
|
if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) ||
|
||||||
string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee))
|
string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee))
|
||||||
throw new HubException("Invalid call participant or room.");
|
throw new HubException("Invalid call participant or room.");
|
||||||
RequireCallPresence(roomName, caller, callee);
|
RequireCallPresence(roomName, caller, callee);
|
||||||
var relayOnly = await RequiresRelayAsync(caller, callee);
|
var relayOnly = await RequiresRelayAsync(caller, callee);
|
||||||
return await _calls.InviteAsync(roomName, caller, callee, Context.ConnectionId,
|
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)
|
public async Task AcceptCall(Guid callId)
|
||||||
{
|
{
|
||||||
var user = CallUser();
|
var user = CallUser();
|
||||||
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
|
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
|
||||||
|
RequireSecureLanControl(session.Transport);
|
||||||
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
|
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
|
||||||
await _calls.AcceptAsync(callId, user, Context.ConnectionId,
|
await _calls.AcceptAsync(callId, user, Context.ConnectionId,
|
||||||
await RequiresRelayAsync(session.Caller, session.Callee));
|
await RequiresRelayAsync(session.Caller, session.Callee));
|
||||||
|
|
@ -36,6 +44,7 @@ public partial class ChatHub
|
||||||
{
|
{
|
||||||
var user = CallUser();
|
var user = CallUser();
|
||||||
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
|
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
|
||||||
|
RequireSecureLanControl(session.Transport);
|
||||||
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
|
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
|
||||||
if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee))
|
if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee))
|
||||||
{
|
{
|
||||||
|
|
@ -53,6 +62,12 @@ public partial class ChatHub
|
||||||
return user;
|
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)
|
private void RequireCallPresence(string room, string caller, string callee)
|
||||||
{
|
{
|
||||||
if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) ||
|
if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) ||
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ public static class CallSignalValidator
|
||||||
{
|
{
|
||||||
if (signal is null)
|
if (signal is null)
|
||||||
throw new HubException("A call signal is required.");
|
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 (signal.Kind is "offer" or "answer")
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(signal.Sdp) || Encoding.UTF8.GetByteCount(signal.Sdp) > 24000 ||
|
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 Offered { get; set; }
|
||||||
public bool Answered { get; set; }
|
public bool Answered { get; set; }
|
||||||
public int CandidateCount { get; set; }
|
public int CandidateCount { get; set; }
|
||||||
|
public HashSet<string> EndpointConnections { get; } = [];
|
||||||
public string[] Connections => [CallerConnection, .. CalleeConnections];
|
public string[] Connections => [CallerConnection, .. CalleeConnections];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,13 +37,13 @@ public sealed class CallSignalingService(
|
||||||
|
|
||||||
public async Task<CallSession> InviteAsync(
|
public async Task<CallSession> InviteAsync(
|
||||||
string room, string caller, string callee, string callerConnection,
|
string room, string caller, string callee, string callerConnection,
|
||||||
string[] calleeConnections, bool relayOnly)
|
string[] calleeConnections, bool relayOnly, string transport = CallTransports.WebRtc)
|
||||||
{
|
{
|
||||||
EnsureEnabled();
|
EnsureEnabled();
|
||||||
if (caller == callee || calleeConnections.Length == 0 ||
|
if (caller == callee || calleeConnections.Length == 0 ||
|
||||||
calleeConnections.Contains(callerConnection))
|
calleeConnections.Contains(callerConnection))
|
||||||
throw new HubException("Call participants are unavailable.");
|
throw new HubException("Call participants are unavailable.");
|
||||||
ValidateIceConfiguration(relayOnly);
|
ValidateTransport(transport, relayOnly);
|
||||||
await _gate.WaitAsync();
|
await _gate.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -52,7 +53,7 @@ public sealed class CallSignalingService(
|
||||||
c.Session.Caller == callee || c.Session.Callee == callee))
|
c.Session.Caller == callee || c.Session.Callee == callee))
|
||||||
throw new HubException("A participant is already in a call.");
|
throw new HubException("A participant is already in a call.");
|
||||||
var session = new CallSession(Guid.NewGuid(), room, caller, callee, relayOnly,
|
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);
|
var call = new PendingCall(session, callerConnection, calleeConnections);
|
||||||
_calls.Add(session.Id, call);
|
_calls.Add(session.Id, call);
|
||||||
try
|
try
|
||||||
|
|
@ -83,7 +84,7 @@ public sealed class CallSignalingService(
|
||||||
if (call.AcceptedConnection != null)
|
if (call.AcceptedConnection != null)
|
||||||
throw new HubException("This call was already accepted.");
|
throw new HubException("This call was already accepted.");
|
||||||
var requireRelay = call.Session.RelayOnly || relayOnly;
|
var requireRelay = call.Session.RelayOnly || relayOnly;
|
||||||
ValidateIceConfiguration(requireRelay);
|
ValidateTransport(call.Session.Transport, requireRelay);
|
||||||
call.AcceptedConnection = connection;
|
call.AcceptedConnection = connection;
|
||||||
call.Session = call.Session with
|
call.Session = call.Session with
|
||||||
{
|
{
|
||||||
|
|
@ -92,7 +93,8 @@ public sealed class CallSignalingService(
|
||||||
};
|
};
|
||||||
var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray();
|
var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray();
|
||||||
call.CalleeConnections.IntersectWith([connection]);
|
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
|
try
|
||||||
{
|
{
|
||||||
if (otherDevices.Length > 0)
|
if (otherDevices.Length > 0)
|
||||||
|
|
@ -122,6 +124,18 @@ public sealed class CallSignalingService(
|
||||||
var callee = call.AcceptedConnection == connection && call.Session.Callee == user;
|
var callee = call.AcceptedConnection == connection && call.Session.Callee == user;
|
||||||
if ((!caller && !callee) || call.AcceptedConnection == null)
|
if ((!caller && !callee) || call.AcceptedConnection == null)
|
||||||
throw new HubException("Media signaling requires acceptance on this device.");
|
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);
|
CallSignalValidator.Validate(signal, call.Session.RelayOnly);
|
||||||
switch (signal.Kind)
|
switch (signal.Kind)
|
||||||
{
|
{
|
||||||
|
|
@ -252,6 +266,19 @@ public sealed class CallSignalingService(
|
||||||
throw new HubException("ICE/TURN configuration is unavailable. No direct fallback is allowed.");
|
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)
|
private static bool ValidUrl(string url, params string[] schemes)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url) || url.Length > 512 || url.Any(char.IsWhiteSpace))
|
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 const string SectionName = "AudioVideoCalls";
|
||||||
|
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
public bool EnableLanCalls { get; set; }
|
||||||
public string[] StunUrls { get; set; } = [];
|
public string[] StunUrls { get; set; } = [];
|
||||||
public string[] TurnUrls { get; set; } = [];
|
public string[] TurnUrls { get; set; } = [];
|
||||||
public string? TurnSharedSecret { get; set; }
|
public string? TurnSharedSecret { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Connections.Features;
|
||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -32,7 +34,8 @@ public sealed class CallHubTests
|
||||||
_services = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider();
|
_services = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider();
|
||||||
Calls = new(Delivery, Options.Create(new AudioVideoCallOptions
|
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);
|
}), TimeProvider.System, NullLogger<CallSignalingService>.Instance);
|
||||||
Db.Users.AddRange(
|
Db.Users.AddRange(
|
||||||
new ApplicationUser { Id = "alice-id", UserName = "alice", Email = "alice@example.test" },
|
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]
|
[Fact]
|
||||||
public async Task Invitation_requires_existing_accounts_presence_and_the_authenticated_connection()
|
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