mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-01 06:28:11 +00:00
Add signal-call-tunnel Rust binary crate
Create the signal-call-tunnel binary that uses RingRTC's native Rust API for ICE/SRTP/call state management. Includes: - config.rs: Config struct read from stdin JSON - control.rs: Unix socket JSON-lines control channel with auth - platform.rs: RingRTC SignalingSender/CallStateHandler/GroupUpdateHandler impls - main.rs: Entry point with CallManager setup and event loop Supports --host-audio flag for system audio, defaults to socket audio mode using the new AudioDeviceModule::new_socket(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f9cbfa6d6c
commit
37ace0ff30
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "third-party/ringrtc"]
|
||||
path = third-party/ringrtc
|
||||
url = https://github.com/signalapp/ringrtc
|
||||
2858
signal-call-tunnel/Cargo.lock
generated
Normal file
2858
signal-call-tunnel/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
signal-call-tunnel/Cargo.toml
Normal file
19
signal-call-tunnel/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "signal-call-tunnel"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ringrtc = { path = "../third-party/ringrtc/src/rust", features = ["prebuilt_webrtc", "virtual_audio"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
base64 = "0.22"
|
||||
anyhow = "1"
|
||||
|
||||
subtle = "2"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use Signal's fork of curve25519-dalek for zkgroup compatibility (matches ringrtc workspace).
|
||||
curve25519-dalek = { git = 'https://github.com/signalapp/curve25519-dalek', tag = 'signal-curve25519-4.1.3' }
|
||||
66
signal-call-tunnel/build.rs
Normal file
66
signal-call-tunnel/build.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Apply the VPIO-disable patch to ringrtc if it hasn't been applied yet.
|
||||
// This is a build-time patch: cargo re-runs build.rs when the patch file
|
||||
// or the target source file changes.
|
||||
let ringrtc_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../third-party/ringrtc");
|
||||
let patch_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("patches/ringrtc-disable-vpio.patch");
|
||||
|
||||
let adm_file = ringrtc_dir.join("src/rust/src/webrtc/audio_device_module.rs");
|
||||
|
||||
println!("cargo::rerun-if-changed={}", patch_file.display());
|
||||
println!("cargo::rerun-if-changed={}", adm_file.display());
|
||||
|
||||
if !patch_file.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the patch is already applied by looking for the marker function.
|
||||
if let Ok(content) = std::fs::read_to_string(&adm_file) {
|
||||
if content.contains("RINGRTC_NO_VOICE_PROCESSING") {
|
||||
// Already applied
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Canonicalize paths so git apply works regardless of how cargo sets cwd.
|
||||
// Run from within the ringrtc directory to avoid parent-repo submodule issues.
|
||||
let ringrtc_canonical = match ringrtc_dir.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Cannot resolve ringrtc path: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let patch_canonical = match patch_file.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Cannot resolve patch path: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("apply")
|
||||
.arg(&patch_canonical)
|
||||
.current_dir(&ringrtc_canonical)
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
eprintln!("cargo:warning=Applied ringrtc VPIO-disable patch for virtual audio support");
|
||||
}
|
||||
Ok(s) => {
|
||||
eprintln!(
|
||||
"cargo:warning=Failed to apply ringrtc patch (exit {}); \
|
||||
VPIO may hang with virtual audio devices",
|
||||
s
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Could not run git apply: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
41
signal-call-tunnel/patches/ringrtc-disable-vpio.patch
Normal file
41
signal-call-tunnel/patches/ringrtc-disable-vpio.patch
Normal file
@ -0,0 +1,41 @@
|
||||
diff --git a/src/rust/src/webrtc/audio_device_module.rs b/src/rust/src/webrtc/audio_device_module.rs
|
||||
index 5e3a6ecf..a9b76c12 100644
|
||||
--- a/src/rust/src/webrtc/audio_device_module.rs
|
||||
+++ b/src/rust/src/webrtc/audio_device_module.rs
|
||||
@@ -265,6 +265,18 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Returns the stream preferences for cubeb audio streams.
|
||||
+ ///
|
||||
+ /// When `RINGRTC_NO_VOICE_PROCESSING` is set, returns `StreamPrefs::NONE`
|
||||
+ /// to skip macOS VoiceProcessingIO which hangs with virtual audio drivers.
|
||||
+ fn stream_prefs() -> StreamPrefs {
|
||||
+ if std::env::var("RINGRTC_NO_VOICE_PROCESSING").is_ok() {
|
||||
+ StreamPrefs::NONE
|
||||
+ } else {
|
||||
+ StreamPrefs::VOICE
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
fn init_playout(&mut self) -> anyhow::Result<()> {
|
||||
let out_device = if let Some(device) = self.playout_device {
|
||||
device
|
||||
@@ -276,7 +288,7 @@ impl Worker {
|
||||
.rate(SAMPLE_FREQUENCY)
|
||||
.channels(2)
|
||||
.layout(cubeb::ChannelLayout::STEREO)
|
||||
- .prefs(StreamPrefs::VOICE)
|
||||
+ .prefs(Self::stream_prefs())
|
||||
.take();
|
||||
let mut builder = cubeb::StreamBuilder::<OutFrame>::new();
|
||||
let transport = Arc::clone(&self.audio_transport);
|
||||
@@ -411,7 +423,7 @@ impl Worker {
|
||||
.rate(SAMPLE_FREQUENCY)
|
||||
.channels(NUM_CHANNELS)
|
||||
.layout(cubeb::ChannelLayout::MONO)
|
||||
- .prefs(StreamPrefs::VOICE)
|
||||
+ .prefs(Self::stream_prefs())
|
||||
.take();
|
||||
|
||||
let mut builder = cubeb::StreamBuilder::<Frame>::new();
|
||||
92
signal-call-tunnel/src/config.rs
Normal file
92
signal-call-tunnel/src/config.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub call_id: u64,
|
||||
pub is_outgoing: bool,
|
||||
pub control_socket_path: String,
|
||||
pub control_token: String,
|
||||
pub local_device_id: u32,
|
||||
pub input_device_name: Option<String>,
|
||||
pub output_device_name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_valid_config() {
|
||||
let json = r#"{
|
||||
"call_id": 12345678,
|
||||
"is_outgoing": true,
|
||||
"control_socket_path": "/tmp/sc-abc/ctrl.sock",
|
||||
"control_token": "dG9rZW4=",
|
||||
"local_device_id": 1
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.call_id, 12345678);
|
||||
assert!(config.is_outgoing);
|
||||
assert_eq!(config.control_socket_path, "/tmp/sc-abc/ctrl.sock");
|
||||
assert_eq!(config.control_token, "dG9rZW4=");
|
||||
assert_eq!(config.local_device_id, 1);
|
||||
assert!(config.input_device_name.is_none());
|
||||
assert!(config.output_device_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_device_names() {
|
||||
let json = r#"{
|
||||
"call_id": 99,
|
||||
"is_outgoing": false,
|
||||
"control_socket_path": "/tmp/ctrl.sock",
|
||||
"control_token": "tok",
|
||||
"local_device_id": 2,
|
||||
"input_device_name": "signal_input",
|
||||
"output_device_name": "signal_output"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.call_id, 99);
|
||||
assert!(!config.is_outgoing);
|
||||
assert_eq!(config.local_device_id, 2);
|
||||
assert_eq!(config.input_device_name.as_deref(), Some("signal_input"));
|
||||
assert_eq!(config.output_device_name.as_deref(), Some("signal_output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_missing_field_fails() {
|
||||
let json = r#"{
|
||||
"call_id": 1,
|
||||
"is_outgoing": true
|
||||
}"#;
|
||||
let result: Result<Config, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_wrong_type_fails() {
|
||||
let json = r#"{
|
||||
"call_id": "not_a_number",
|
||||
"is_outgoing": true,
|
||||
"control_socket_path": "/tmp/ctrl.sock",
|
||||
"control_token": "tok",
|
||||
"local_device_id": 1
|
||||
}"#;
|
||||
let result: Result<Config, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_extra_fields_ok() {
|
||||
let json = r#"{
|
||||
"call_id": 1,
|
||||
"is_outgoing": true,
|
||||
"control_socket_path": "/tmp/ctrl.sock",
|
||||
"control_token": "tok",
|
||||
"local_device_id": 1,
|
||||
"extra_field": "ignored"
|
||||
}"#;
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.call_id, 1);
|
||||
}
|
||||
}
|
||||
521
signal-call-tunnel/src/control.rs
Normal file
521
signal-call-tunnel/src/control.rs
Normal file
@ -0,0 +1,521 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use log::{error, info, warn};
|
||||
use serde_json::Value;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::platform::PlatformEvent;
|
||||
|
||||
/// Messages parsed from the parent process.
|
||||
#[derive(Debug)]
|
||||
pub enum ControlMessage {
|
||||
Auth { token: String },
|
||||
CreateOutgoingCall { call_id: u64, peer_id: String },
|
||||
Proceed { call_id: u64, ice_servers: Vec<IceServerConfig>, hide_ip: bool },
|
||||
ReceivedOffer {
|
||||
call_id: u64,
|
||||
peer_id: String,
|
||||
sender_device_id: u32,
|
||||
opaque: Vec<u8>,
|
||||
age_ms: u64,
|
||||
sender_identity_key: Vec<u8>,
|
||||
receiver_identity_key: Vec<u8>,
|
||||
},
|
||||
ReceivedAnswer {
|
||||
opaque: Vec<u8>,
|
||||
sender_device_id: u32,
|
||||
sender_identity_key: Vec<u8>,
|
||||
receiver_identity_key: Vec<u8>,
|
||||
},
|
||||
ReceivedIce { candidates: Vec<Vec<u8>> },
|
||||
Accept,
|
||||
Hangup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IceServerConfig {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub urls: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a JSON line into a ControlMessage.
|
||||
pub fn parse_message(line: &str) -> Result<ControlMessage> {
|
||||
let v: Value = serde_json::from_str(line).context("invalid JSON")?;
|
||||
let msg_type = v["type"].as_str().unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"auth" => Ok(ControlMessage::Auth {
|
||||
token: v["token"].as_str().unwrap_or("").to_string(),
|
||||
}),
|
||||
"createOutgoingCall" => Ok(ControlMessage::CreateOutgoingCall {
|
||||
call_id: v["callId"].as_u64().unwrap_or(0),
|
||||
peer_id: v["peerId"].as_str().unwrap_or("").to_string(),
|
||||
}),
|
||||
"proceed" => {
|
||||
let ice_servers = if let Some(servers) = v["iceServers"].as_array() {
|
||||
servers
|
||||
.iter()
|
||||
.map(|s| IceServerConfig {
|
||||
username: s["username"].as_str().unwrap_or("").to_string(),
|
||||
password: s["password"].as_str().unwrap_or("").to_string(),
|
||||
urls: s["urls"]
|
||||
.as_array()
|
||||
.map(|urls| {
|
||||
urls.iter()
|
||||
.filter_map(|u| u.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(ControlMessage::Proceed {
|
||||
call_id: v["callId"].as_u64().unwrap_or(0),
|
||||
ice_servers,
|
||||
hide_ip: v["hideIp"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
"receivedOffer" => Ok(ControlMessage::ReceivedOffer {
|
||||
call_id: v["callId"].as_u64().unwrap_or(0),
|
||||
peer_id: v["peerId"].as_str().unwrap_or("remote").to_string(),
|
||||
sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32,
|
||||
opaque: BASE64
|
||||
.decode(v["opaque"].as_str().unwrap_or(""))
|
||||
.unwrap_or_default(),
|
||||
age_ms: v["age"].as_u64().unwrap_or(0),
|
||||
sender_identity_key: BASE64
|
||||
.decode(v["senderIdentityKey"].as_str().unwrap_or(""))
|
||||
.unwrap_or_default(),
|
||||
receiver_identity_key: BASE64
|
||||
.decode(v["receiverIdentityKey"].as_str().unwrap_or(""))
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
"receivedAnswer" => Ok(ControlMessage::ReceivedAnswer {
|
||||
opaque: BASE64
|
||||
.decode(v["opaque"].as_str().unwrap_or(""))
|
||||
.unwrap_or_default(),
|
||||
sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32,
|
||||
sender_identity_key: BASE64
|
||||
.decode(v["senderIdentityKey"].as_str().unwrap_or(""))
|
||||
.unwrap_or_default(),
|
||||
receiver_identity_key: BASE64
|
||||
.decode(v["receiverIdentityKey"].as_str().unwrap_or(""))
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
"receivedIce" => {
|
||||
let candidates = if let Some(arr) = v["candidates"].as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|c| {
|
||||
let b64 = c.as_str()?;
|
||||
BASE64.decode(b64).ok()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(ControlMessage::ReceivedIce { candidates })
|
||||
}
|
||||
"accept" => Ok(ControlMessage::Accept),
|
||||
"hangup" => Ok(ControlMessage::Hangup),
|
||||
_ => bail!("unknown message type: {}", msg_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the auth token using constant-time comparison.
|
||||
pub fn validate_token(received: &str, expected: &str) -> bool {
|
||||
let received_bytes = received.as_bytes();
|
||||
let expected_bytes = expected.as_bytes();
|
||||
if received_bytes.len() != expected_bytes.len() {
|
||||
return false;
|
||||
}
|
||||
received_bytes.ct_eq(expected_bytes).into()
|
||||
}
|
||||
|
||||
/// Runs the control channel server. Binds a Unix socket, accepts one connection,
|
||||
/// validates auth, then reads messages and sends events.
|
||||
///
|
||||
/// Returns a channel receiver for incoming control messages and a writer for
|
||||
/// sending events to the parent.
|
||||
pub struct ControlChannel {
|
||||
pub msg_receiver: mpsc::Receiver<ControlMessage>,
|
||||
pub writer: ControlWriter,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControlWriter {
|
||||
sender: mpsc::Sender<String>,
|
||||
}
|
||||
|
||||
impl ControlWriter {
|
||||
pub fn send_line(&self, line: &str) {
|
||||
if let Err(e) = self.sender.send(line.to_string()) {
|
||||
error!("Failed to send to control writer: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_event(&self, event: &PlatformEvent) {
|
||||
self.send_line(&event.to_json());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- parse_message tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_auth() {
|
||||
let msg = parse_message(r#"{"type":"auth","token":"secret123"}"#).unwrap();
|
||||
match msg {
|
||||
ControlMessage::Auth { token } => assert_eq!(token, "secret123"),
|
||||
_ => panic!("expected Auth, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_create_outgoing_call() {
|
||||
let msg = parse_message(
|
||||
r#"{"type":"createOutgoingCall","callId":42,"peerId":"abc-def"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
match msg {
|
||||
ControlMessage::CreateOutgoingCall { call_id, peer_id } => {
|
||||
assert_eq!(call_id, 42);
|
||||
assert_eq!(peer_id, "abc-def");
|
||||
}
|
||||
_ => panic!("expected CreateOutgoingCall, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proceed_with_ice_servers() {
|
||||
let json = r#"{
|
||||
"type": "proceed",
|
||||
"callId": 99,
|
||||
"hideIp": true,
|
||||
"iceServers": [
|
||||
{
|
||||
"username": "user1",
|
||||
"password": "pass1",
|
||||
"urls": ["turn:example.com:3478", "stun:example.com:3478"]
|
||||
},
|
||||
{
|
||||
"username": "user2",
|
||||
"password": "pass2",
|
||||
"urls": ["turn:other.com:443"]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let msg = parse_message(json).unwrap();
|
||||
match msg {
|
||||
ControlMessage::Proceed {
|
||||
call_id,
|
||||
ice_servers,
|
||||
hide_ip,
|
||||
} => {
|
||||
assert_eq!(call_id, 99);
|
||||
assert!(hide_ip);
|
||||
assert_eq!(ice_servers.len(), 2);
|
||||
assert_eq!(ice_servers[0].username, "user1");
|
||||
assert_eq!(ice_servers[0].password, "pass1");
|
||||
assert_eq!(ice_servers[0].urls.len(), 2);
|
||||
assert_eq!(ice_servers[0].urls[0], "turn:example.com:3478");
|
||||
assert_eq!(ice_servers[1].username, "user2");
|
||||
assert_eq!(ice_servers[1].urls.len(), 1);
|
||||
}
|
||||
_ => panic!("expected Proceed, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_proceed_no_ice_servers() {
|
||||
let msg = parse_message(r#"{"type":"proceed","callId":1}"#).unwrap();
|
||||
match msg {
|
||||
ControlMessage::Proceed {
|
||||
call_id,
|
||||
ice_servers,
|
||||
hide_ip,
|
||||
} => {
|
||||
assert_eq!(call_id, 1);
|
||||
assert!(!hide_ip);
|
||||
assert!(ice_servers.is_empty());
|
||||
}
|
||||
_ => panic!("expected Proceed, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_received_offer() {
|
||||
// "aGVsbG8=" is base64 for "hello"
|
||||
let json = r#"{
|
||||
"type": "receivedOffer",
|
||||
"callId": 100,
|
||||
"peerId": "0b949a17-dc53-41b1-9ebc-dea99cb93920",
|
||||
"senderDeviceId": 3,
|
||||
"opaque": "aGVsbG8=",
|
||||
"age": 500,
|
||||
"senderIdentityKey": "AQID",
|
||||
"receiverIdentityKey": "BAUG"
|
||||
}"#;
|
||||
let msg = parse_message(json).unwrap();
|
||||
match msg {
|
||||
ControlMessage::ReceivedOffer {
|
||||
call_id,
|
||||
peer_id,
|
||||
sender_device_id,
|
||||
opaque,
|
||||
age_ms,
|
||||
sender_identity_key,
|
||||
receiver_identity_key,
|
||||
} => {
|
||||
assert_eq!(call_id, 100);
|
||||
assert_eq!(peer_id, "0b949a17-dc53-41b1-9ebc-dea99cb93920");
|
||||
assert_eq!(sender_device_id, 3);
|
||||
assert_eq!(opaque, b"hello");
|
||||
assert_eq!(age_ms, 500);
|
||||
assert_eq!(sender_identity_key, vec![1, 2, 3]);
|
||||
assert_eq!(receiver_identity_key, vec![4, 5, 6]);
|
||||
}
|
||||
_ => panic!("expected ReceivedOffer, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_received_answer() {
|
||||
let json = r#"{
|
||||
"type": "receivedAnswer",
|
||||
"opaque": "AQID",
|
||||
"senderDeviceId": 2,
|
||||
"senderIdentityKey": "BAUG",
|
||||
"receiverIdentityKey": "BwgJ"
|
||||
}"#;
|
||||
let msg = parse_message(json).unwrap();
|
||||
match msg {
|
||||
ControlMessage::ReceivedAnswer {
|
||||
opaque,
|
||||
sender_device_id,
|
||||
sender_identity_key,
|
||||
receiver_identity_key,
|
||||
} => {
|
||||
assert_eq!(opaque, vec![1, 2, 3]);
|
||||
assert_eq!(sender_device_id, 2);
|
||||
assert_eq!(sender_identity_key, vec![4, 5, 6]);
|
||||
assert_eq!(receiver_identity_key, vec![7, 8, 9]);
|
||||
}
|
||||
_ => panic!("expected ReceivedAnswer, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_received_ice() {
|
||||
// Two base64-encoded candidates
|
||||
let json = r#"{"type":"receivedIce","candidates":["AQID","BAUG"]}"#;
|
||||
let msg = parse_message(json).unwrap();
|
||||
match msg {
|
||||
ControlMessage::ReceivedIce { candidates } => {
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert_eq!(candidates[0], vec![1, 2, 3]);
|
||||
assert_eq!(candidates[1], vec![4, 5, 6]);
|
||||
}
|
||||
_ => panic!("expected ReceivedIce, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_received_ice_empty() {
|
||||
let msg = parse_message(r#"{"type":"receivedIce","candidates":[]}"#).unwrap();
|
||||
match msg {
|
||||
ControlMessage::ReceivedIce { candidates } => {
|
||||
assert!(candidates.is_empty());
|
||||
}
|
||||
_ => panic!("expected ReceivedIce, got {:?}", msg),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accept() {
|
||||
let msg = parse_message(r#"{"type":"accept"}"#).unwrap();
|
||||
assert!(matches!(msg, ControlMessage::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hangup() {
|
||||
let msg = parse_message(r#"{"type":"hangup"}"#).unwrap();
|
||||
assert!(matches!(msg, ControlMessage::Hangup));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_type_fails() {
|
||||
let result = parse_message(r#"{"type":"foobar"}"#);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("unknown message type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_json_fails() {
|
||||
let result = parse_message("not json at all");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_type_fails() {
|
||||
let result = parse_message(r#"{"callId":1}"#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- validate_token tests ---
|
||||
|
||||
#[test]
|
||||
fn validate_token_matching() {
|
||||
assert!(validate_token("my-secret-token", "my-secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_mismatch() {
|
||||
assert!(!validate_token("wrong-token", "my-secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_different_lengths() {
|
||||
assert!(!validate_token("short", "a-much-longer-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_empty() {
|
||||
assert!(validate_token("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_one_empty() {
|
||||
assert!(!validate_token("", "notempty"));
|
||||
assert!(!validate_token("notempty", ""));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_control_channel(
|
||||
control_socket_path: &str,
|
||||
expected_token: &str,
|
||||
input_device_name: &str,
|
||||
output_device_name: &str,
|
||||
) -> Result<ControlChannel> {
|
||||
// Remove stale socket file
|
||||
let _ = std::fs::remove_file(control_socket_path);
|
||||
|
||||
let listener = UnixListener::bind(control_socket_path)
|
||||
.with_context(|| format!("failed to bind control socket at {}", control_socket_path))?;
|
||||
info!("Control channel listening on {}", control_socket_path);
|
||||
|
||||
let (msg_sender, msg_receiver) = mpsc::channel::<ControlMessage>();
|
||||
let (write_sender, write_receiver) = mpsc::channel::<String>();
|
||||
let writer = ControlWriter {
|
||||
sender: write_sender,
|
||||
};
|
||||
|
||||
// Send ready message immediately (parent can connect after this)
|
||||
let ready_msg = format!(
|
||||
r#"{{"type":"ready","inputDeviceName":"{}","outputDeviceName":"{}"}}"#,
|
||||
input_device_name, output_device_name
|
||||
);
|
||||
|
||||
let expected_token = expected_token.to_string();
|
||||
|
||||
// Spawn reader thread
|
||||
std::thread::spawn(move || {
|
||||
// Accept one connection
|
||||
let (stream, _) = match listener.accept() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to accept control connection: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!("Control channel: parent connected");
|
||||
|
||||
let mut writer_stream = match stream.try_clone() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to clone control stream: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn writer thread
|
||||
std::thread::spawn(move || {
|
||||
for line in write_receiver {
|
||||
if let Err(e) = writeln!(writer_stream, "{}", line) {
|
||||
error!("Failed to write to control channel: {}", e);
|
||||
break;
|
||||
}
|
||||
if let Err(e) = writer_stream.flush() {
|
||||
error!("Failed to flush control channel: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reader = BufReader::new(stream);
|
||||
let mut authenticated = false;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
info!("Control channel read ended: {}", e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg = match parse_message(&line) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse control message: {} (line: {})", e, line);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// First message must be auth
|
||||
if !authenticated {
|
||||
if let ControlMessage::Auth { ref token } = msg {
|
||||
if validate_token(token, &expected_token) {
|
||||
authenticated = true;
|
||||
info!("Control channel: authenticated");
|
||||
continue;
|
||||
} else {
|
||||
error!("Control channel: auth failed");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
error!("Control channel: first message must be auth");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = msg_sender.send(msg) {
|
||||
info!("Control message receiver dropped: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send the ready message through the writer channel
|
||||
// (it will be sent once the writer thread starts)
|
||||
writer.send_line(&ready_msg);
|
||||
|
||||
Ok(ControlChannel {
|
||||
msg_receiver,
|
||||
writer,
|
||||
})
|
||||
}
|
||||
410
signal-call-tunnel/src/main.rs
Normal file
410
signal-call-tunnel/src/main.rs
Normal file
@ -0,0 +1,410 @@
|
||||
mod config;
|
||||
mod control;
|
||||
mod platform;
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use log::{debug, error, info};
|
||||
|
||||
use ringrtc::common::{CallConfig, CallId, CallMediaType, DataMode, DeviceId};
|
||||
use ringrtc::core::{call_manager::CallManager, signaling};
|
||||
use ringrtc::lite::http;
|
||||
use ringrtc::native::{NativeCallContext, NativePlatform, PeerId};
|
||||
use ringrtc::virtual_audio::VirtualAudioDevicePair;
|
||||
use ringrtc::webrtc::{
|
||||
media::{VideoFrame, VideoSink},
|
||||
peer_connection_factory::{AudioConfig, IceServer, PeerConnectionFactory},
|
||||
};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::control::{ControlMessage, start_control_channel};
|
||||
use crate::platform::{
|
||||
PlatformEvent, TunnelGroupHandler, TunnelSignalingSender, TunnelStateHandler,
|
||||
};
|
||||
|
||||
/// Dummy video sink that discards all frames.
|
||||
#[derive(Debug)]
|
||||
struct NullVideoSink;
|
||||
|
||||
impl VideoSink for NullVideoSink {
|
||||
fn on_video_frame(&self, _track_id: u32, _frame: VideoFrame) {}
|
||||
fn box_clone(&self) -> Box<dyn VideoSink> {
|
||||
Box::new(NullVideoSink)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dummy HTTP client for CallManager (no SFU needed for 1:1 calls).
|
||||
#[derive(Clone)]
|
||||
struct NullHttpClient;
|
||||
|
||||
impl http::Delegate for NullHttpClient {
|
||||
fn send_request(&self, _request_id: u32, _request: http::Request) {
|
||||
// No-op -- no group call SFU requests
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
.format_timestamp_millis()
|
||||
.init();
|
||||
|
||||
// Read config from stdin
|
||||
let mut config_str = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut config_str)
|
||||
.context("failed to read config from stdin")?;
|
||||
let config: Config =
|
||||
serde_json::from_str(&config_str).context("failed to parse config JSON")?;
|
||||
|
||||
info!(
|
||||
"signal-call-tunnel starting: call_id={}, is_outgoing={}",
|
||||
config.call_id, config.is_outgoing
|
||||
);
|
||||
|
||||
// Create virtual audio devices (signal-call-tunnel owns their lifecycle).
|
||||
// On macOS, BlackHole drivers must be pre-installed with matching names (requires
|
||||
// root), so we default to fixed names. On Linux, PulseAudio virtual sinks are
|
||||
// created dynamically, so per-call unique names avoid collisions.
|
||||
let input_name = config.input_device_name.clone().unwrap_or_else(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
"signal_input".to_string()
|
||||
} else {
|
||||
format!("signal_input_{}", config.call_id)
|
||||
}
|
||||
});
|
||||
let output_name = config.output_device_name.clone().unwrap_or_else(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
"signal_output".to_string()
|
||||
} else {
|
||||
format!("signal_output_{}", config.call_id)
|
||||
}
|
||||
});
|
||||
|
||||
let virtual_audio = VirtualAudioDevicePair::new(&input_name, &output_name)?;
|
||||
info!(
|
||||
"Virtual audio devices: input={}, output={}",
|
||||
virtual_audio.input_source(),
|
||||
virtual_audio.output_sink()
|
||||
);
|
||||
|
||||
// Channel for platform events (signaling + state changes)
|
||||
let (event_sender, event_receiver) = mpsc::channel::<PlatformEvent>();
|
||||
|
||||
// Start control channel
|
||||
let control = start_control_channel(
|
||||
&config.control_socket_path,
|
||||
&config.control_token,
|
||||
virtual_audio.input_source(),
|
||||
virtual_audio.output_sink(),
|
||||
)?;
|
||||
|
||||
// Show WebRTC logs while debugging
|
||||
#[cfg(debug_assertions)]
|
||||
ringrtc::webrtc::logging::set_logger(log::LevelFilter::Debug);
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
ringrtc::webrtc::logging::set_logger(log::LevelFilter::Warn);
|
||||
|
||||
// Disable macOS VPIO (VoiceProcessingIO) for cubeb audio streams.
|
||||
// VPIO creates an aggregate device that hangs with BlackHole virtual audio
|
||||
// drivers. Voice processing (AEC/AGC/NS) is unnecessary for virtual audio.
|
||||
// Safety: called before any threads are spawned, single-threaded at this point.
|
||||
unsafe { std::env::set_var("RINGRTC_NO_VOICE_PROCESSING", "1") };
|
||||
|
||||
let audio_config = AudioConfig::default();
|
||||
let mut pcf = PeerConnectionFactory::new(&audio_config, false, "", None)?;
|
||||
|
||||
// Wait for cubeb to enumerate the virtual devices
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
if pcf
|
||||
.get_audio_playout_devices()
|
||||
.is_ok_and(|d| !d.is_empty())
|
||||
&& pcf
|
||||
.get_audio_recording_devices()
|
||||
.is_ok_and(|d| !d.is_empty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Select virtual devices by name.
|
||||
//
|
||||
// We can't use set_audio_*_device_by_id() because the ADM matches on the
|
||||
// cubeb unique_id (e.g. "signal_input2ch_UID"), not the friendly name we
|
||||
// know ("signal_input"). Instead, enumerate and find the index by name.
|
||||
let input_name = virtual_audio.input_source();
|
||||
let recording_devices = pcf.get_audio_recording_devices()?;
|
||||
let recording_index = recording_devices
|
||||
.iter()
|
||||
.position(|d| d.name == input_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("recording device '{}' not found", input_name))?
|
||||
as u16;
|
||||
pcf.set_audio_recording_device(recording_index)?;
|
||||
info!("Selected recording device: index={}, name={}", recording_index, input_name);
|
||||
|
||||
let output_name = virtual_audio.output_sink();
|
||||
let playout_devices = pcf.get_audio_playout_devices()?;
|
||||
let playout_index = playout_devices
|
||||
.iter()
|
||||
.position(|d| d.name == output_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("playout device '{}' not found", output_name))?
|
||||
as u16;
|
||||
pcf.set_audio_playout_device(playout_index)?;
|
||||
info!("Selected playout device: index={}, name={}", playout_index, output_name);
|
||||
|
||||
// Create platform with our trait implementations
|
||||
let signaling_sender = Box::new(TunnelSignalingSender {
|
||||
event_sender: event_sender.clone(),
|
||||
});
|
||||
let state_handler = Box::new(TunnelStateHandler {
|
||||
event_sender: event_sender.clone(),
|
||||
});
|
||||
let group_handler = Box::new(TunnelGroupHandler);
|
||||
|
||||
let platform = NativePlatform::new(
|
||||
pcf.clone(),
|
||||
signaling_sender,
|
||||
true, // should_assume_messages_sent
|
||||
state_handler,
|
||||
group_handler,
|
||||
);
|
||||
|
||||
let http_client = http::DelegatingClient::new(NullHttpClient);
|
||||
let mut call_manager = CallManager::new(platform, http_client)?;
|
||||
|
||||
// Peer ID is set by the first createOutgoingCall or receivedOffer message.
|
||||
let mut active_peer_id = PeerId::from("remote");
|
||||
let call_id = CallId::from(config.call_id);
|
||||
let local_device_id = config.local_device_id as DeviceId;
|
||||
|
||||
info!("CallManager initialized, entering event loop");
|
||||
|
||||
// Spawn a thread to forward platform events to control channel
|
||||
let control_writer = control.writer.clone();
|
||||
std::thread::spawn(move || {
|
||||
for event in event_receiver {
|
||||
control_writer.send_event(&event);
|
||||
}
|
||||
});
|
||||
|
||||
// Main event loop: process control messages
|
||||
loop {
|
||||
let msg = match control.msg_receiver.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(msg) => msg,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
info!("Control channel disconnected, exiting");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
ControlMessage::Auth { .. } => {
|
||||
// Already handled by control channel
|
||||
}
|
||||
ControlMessage::CreateOutgoingCall {
|
||||
call_id: cid,
|
||||
peer_id: pid,
|
||||
} => {
|
||||
let call_id = CallId::from(cid);
|
||||
let peer_id = PeerId::from(pid.as_str());
|
||||
active_peer_id = peer_id.clone();
|
||||
info!("Creating outgoing call: call_id={}, peer_id={}", cid, pid);
|
||||
if let Err(e) = call_manager.create_outgoing_call(
|
||||
peer_id,
|
||||
call_id,
|
||||
CallMediaType::Audio,
|
||||
local_device_id,
|
||||
) {
|
||||
error!("Failed to create outgoing call: {}", e);
|
||||
control.writer.send_line(&format!(
|
||||
r#"{{"type":"error","message":"failed to create outgoing call: {}"}}"#,
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
ControlMessage::Proceed {
|
||||
call_id: cid,
|
||||
ice_servers,
|
||||
hide_ip,
|
||||
} => {
|
||||
let call_id = CallId::from(cid);
|
||||
info!("Proceeding with call: call_id={}", cid);
|
||||
|
||||
let ice_server_list: Vec<IceServer> = ice_servers
|
||||
.iter()
|
||||
.map(|s| IceServer::new(
|
||||
s.username.clone(),
|
||||
s.password.clone(),
|
||||
String::new(),
|
||||
s.urls.clone(),
|
||||
))
|
||||
.collect();
|
||||
|
||||
let outgoing_audio_track = match pcf.create_outgoing_audio_track() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create audio track: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let outgoing_video_source = match pcf.create_outgoing_video_source() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to create video source: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let outgoing_video_track =
|
||||
match pcf.create_outgoing_video_track(&outgoing_video_source) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create video track: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let call_context = NativeCallContext::new(
|
||||
hide_ip,
|
||||
ice_server_list,
|
||||
outgoing_audio_track,
|
||||
outgoing_video_track,
|
||||
Box::new(NullVideoSink),
|
||||
);
|
||||
|
||||
let call_config = CallConfig {
|
||||
data_mode: DataMode::Low,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
call_manager.proceed(call_id, call_context, call_config, None)
|
||||
{
|
||||
error!("Failed to proceed: {}", e);
|
||||
control.writer.send_line(&format!(
|
||||
r#"{{"type":"error","message":"failed to proceed: {}"}}"#,
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
ControlMessage::ReceivedOffer {
|
||||
call_id: cid,
|
||||
peer_id: pid,
|
||||
sender_device_id,
|
||||
opaque,
|
||||
age_ms,
|
||||
sender_identity_key,
|
||||
receiver_identity_key,
|
||||
} => {
|
||||
let call_id = CallId::from(cid);
|
||||
let peer_id = PeerId::from(pid.as_str());
|
||||
active_peer_id = peer_id.clone();
|
||||
info!(
|
||||
"Received offer: call_id={}, peer_id={}, sender_device={}",
|
||||
cid, pid, sender_device_id
|
||||
);
|
||||
|
||||
let offer = match signaling::Offer::new(CallMediaType::Audio, opaque) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
error!("Failed to parse offer: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let received = signaling::ReceivedOffer {
|
||||
offer,
|
||||
age: Duration::from_millis(age_ms),
|
||||
sender_device_id: sender_device_id as DeviceId,
|
||||
receiver_device_id: local_device_id,
|
||||
sender_identity_key,
|
||||
receiver_identity_key,
|
||||
};
|
||||
|
||||
if let Err(e) = call_manager.received_offer(
|
||||
peer_id,
|
||||
call_id,
|
||||
received,
|
||||
) {
|
||||
error!("Failed to process received offer: {}", e);
|
||||
}
|
||||
}
|
||||
ControlMessage::ReceivedAnswer {
|
||||
opaque,
|
||||
sender_device_id,
|
||||
sender_identity_key,
|
||||
receiver_identity_key,
|
||||
} => {
|
||||
info!("Received answer from device {}", sender_device_id);
|
||||
|
||||
let answer = match signaling::Answer::new(opaque) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
error!("Failed to parse answer: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let received = signaling::ReceivedAnswer {
|
||||
answer,
|
||||
sender_device_id: sender_device_id as DeviceId,
|
||||
sender_identity_key,
|
||||
receiver_identity_key,
|
||||
};
|
||||
|
||||
if let Err(e) = call_manager.received_answer(
|
||||
active_peer_id.clone(),
|
||||
call_id,
|
||||
received,
|
||||
) {
|
||||
error!("Failed to process received answer: {}", e);
|
||||
}
|
||||
}
|
||||
ControlMessage::ReceivedIce { candidates } => {
|
||||
debug!("Received {} ICE candidates", candidates.len());
|
||||
|
||||
let ice_candidates: Vec<signaling::IceCandidate> = candidates
|
||||
.into_iter()
|
||||
.map(signaling::IceCandidate::new)
|
||||
.collect();
|
||||
|
||||
let received = signaling::ReceivedIce {
|
||||
ice: signaling::Ice {
|
||||
candidates: ice_candidates,
|
||||
},
|
||||
sender_device_id: 1 as DeviceId,
|
||||
};
|
||||
|
||||
if let Err(e) = call_manager.received_ice(
|
||||
active_peer_id.clone(),
|
||||
call_id,
|
||||
received,
|
||||
) {
|
||||
error!("Failed to process received ICE: {}", e);
|
||||
}
|
||||
}
|
||||
ControlMessage::Accept => {
|
||||
info!("Accepting call");
|
||||
if let Err(e) = call_manager.accept_call(call_id) {
|
||||
error!("Failed to accept call: {}", e);
|
||||
}
|
||||
}
|
||||
ControlMessage::Hangup => {
|
||||
info!("Hanging up");
|
||||
if let Err(e) = call_manager.hangup() {
|
||||
error!("Failed to hangup: {}", e);
|
||||
}
|
||||
// Give time for hangup to be sent
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("signal-call-tunnel exiting");
|
||||
Ok(())
|
||||
}
|
||||
481
signal-call-tunnel/src/platform.rs
Normal file
481
signal-call-tunnel/src/platform.rs
Normal file
@ -0,0 +1,481 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use log::{debug, error, info, warn};
|
||||
|
||||
use ringrtc::common::{CallId, CallMediaType, DeviceId};
|
||||
use ringrtc::core::{group_call, signaling};
|
||||
use ringrtc::lite::sfu::UserId;
|
||||
use ringrtc::native::{
|
||||
CallState, CallStateHandler, GroupUpdate, GroupUpdateHandler, SignalingSender,
|
||||
};
|
||||
use ringrtc::webrtc::peer_connection::AudioLevel;
|
||||
use ringrtc::webrtc::peer_connection_observer::NetworkRoute;
|
||||
|
||||
/// Events sent from the platform callbacks to the control channel writer.
|
||||
#[derive(Debug)]
|
||||
pub enum PlatformEvent {
|
||||
/// A signaling message to send to the parent process.
|
||||
SendSignaling {
|
||||
call_id: CallId,
|
||||
message: SignalingEvent,
|
||||
},
|
||||
/// A call state change.
|
||||
StateChange {
|
||||
state: String,
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SignalingEvent {
|
||||
SendOffer {
|
||||
opaque: Vec<u8>,
|
||||
call_media_type: CallMediaType,
|
||||
},
|
||||
SendAnswer {
|
||||
opaque: Vec<u8>,
|
||||
},
|
||||
SendIce {
|
||||
candidates: Vec<Vec<u8>>,
|
||||
},
|
||||
SendHangup {
|
||||
hangup_type: String,
|
||||
},
|
||||
SendBusy,
|
||||
}
|
||||
|
||||
impl PlatformEvent {
|
||||
pub fn to_json(&self) -> String {
|
||||
match self {
|
||||
PlatformEvent::SendSignaling { call_id, message } => match message {
|
||||
SignalingEvent::SendOffer {
|
||||
opaque,
|
||||
call_media_type,
|
||||
} => {
|
||||
let media_type = match call_media_type {
|
||||
CallMediaType::Audio => "audio",
|
||||
CallMediaType::Video => "video",
|
||||
};
|
||||
format!(
|
||||
r#"{{"type":"sendOffer","callId":{},"opaque":"{}","callMediaType":"{}"}}"#,
|
||||
u64::from(*call_id),
|
||||
BASE64.encode(opaque),
|
||||
media_type,
|
||||
)
|
||||
}
|
||||
SignalingEvent::SendAnswer { opaque } => {
|
||||
format!(
|
||||
r#"{{"type":"sendAnswer","callId":{},"opaque":"{}"}}"#,
|
||||
u64::from(*call_id),
|
||||
BASE64.encode(opaque),
|
||||
)
|
||||
}
|
||||
SignalingEvent::SendIce { candidates } => {
|
||||
let candidates_json: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| format!(r#"{{"opaque":"{}"}}"#, BASE64.encode(c)))
|
||||
.collect();
|
||||
format!(
|
||||
r#"{{"type":"sendIce","callId":{},"candidates":[{}]}}"#,
|
||||
u64::from(*call_id),
|
||||
candidates_json.join(","),
|
||||
)
|
||||
}
|
||||
SignalingEvent::SendHangup { hangup_type } => {
|
||||
format!(
|
||||
r#"{{"type":"sendHangup","callId":{},"hangupType":"{}"}}"#,
|
||||
u64::from(*call_id),
|
||||
hangup_type,
|
||||
)
|
||||
}
|
||||
SignalingEvent::SendBusy => {
|
||||
format!(
|
||||
r#"{{"type":"sendBusy","callId":{}}}"#,
|
||||
u64::from(*call_id),
|
||||
)
|
||||
}
|
||||
},
|
||||
PlatformEvent::StateChange { state, reason } => {
|
||||
if let Some(reason) = reason {
|
||||
format!(
|
||||
r#"{{"type":"stateChange","state":"{}","reason":"{}"}}"#,
|
||||
state, reason,
|
||||
)
|
||||
} else {
|
||||
format!(r#"{{"type":"stateChange","state":"{}"}}"#, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn parse_event_json(event: &PlatformEvent) -> Value {
|
||||
serde_json::from_str(&event.to_json()).expect("event JSON should be valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_offer_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(42u64),
|
||||
message: SignalingEvent::SendOffer {
|
||||
opaque: vec![1, 2, 3],
|
||||
call_media_type: CallMediaType::Audio,
|
||||
},
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "sendOffer");
|
||||
assert_eq!(json["callId"], 42);
|
||||
assert_eq!(json["callMediaType"], "audio");
|
||||
// Verify opaque is valid base64 that decodes back
|
||||
let opaque_b64 = json["opaque"].as_str().unwrap();
|
||||
let decoded = BASE64.decode(opaque_b64).unwrap();
|
||||
assert_eq!(decoded, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_offer_video_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(1u64),
|
||||
message: SignalingEvent::SendOffer {
|
||||
opaque: vec![],
|
||||
call_media_type: CallMediaType::Video,
|
||||
},
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["callMediaType"], "video");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_answer_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(99u64),
|
||||
message: SignalingEvent::SendAnswer {
|
||||
opaque: vec![4, 5, 6],
|
||||
},
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "sendAnswer");
|
||||
assert_eq!(json["callId"], 99);
|
||||
let decoded = BASE64.decode(json["opaque"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(decoded, vec![4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_ice_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(7u64),
|
||||
message: SignalingEvent::SendIce {
|
||||
candidates: vec![vec![10, 20], vec![30, 40]],
|
||||
},
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "sendIce");
|
||||
assert_eq!(json["callId"], 7);
|
||||
let candidates = json["candidates"].as_array().unwrap();
|
||||
assert_eq!(candidates.len(), 2);
|
||||
let c0 = BASE64
|
||||
.decode(candidates[0]["opaque"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(c0, vec![10, 20]);
|
||||
let c1 = BASE64
|
||||
.decode(candidates[1]["opaque"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(c1, vec![30, 40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_ice_empty_candidates_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(1u64),
|
||||
message: SignalingEvent::SendIce {
|
||||
candidates: vec![],
|
||||
},
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["candidates"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_hangup_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(50u64),
|
||||
message: SignalingEvent::SendHangup {
|
||||
hangup_type: "normal".to_string(),
|
||||
},
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "sendHangup");
|
||||
assert_eq!(json["callId"], 50);
|
||||
assert_eq!(json["hangupType"], "normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_busy_json() {
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(8u64),
|
||||
message: SignalingEvent::SendBusy,
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "sendBusy");
|
||||
assert_eq!(json["callId"], 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_change_with_reason_json() {
|
||||
let event = PlatformEvent::StateChange {
|
||||
state: "Ended".to_string(),
|
||||
reason: Some("Timeout".to_string()),
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "stateChange");
|
||||
assert_eq!(json["state"], "Ended");
|
||||
assert_eq!(json["reason"], "Timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_change_without_reason_json() {
|
||||
let event = PlatformEvent::StateChange {
|
||||
state: "Connected".to_string(),
|
||||
reason: None,
|
||||
};
|
||||
let json = parse_event_json(&event);
|
||||
assert_eq!(json["type"], "stateChange");
|
||||
assert_eq!(json["state"], "Connected");
|
||||
assert!(json.get("reason").is_none());
|
||||
}
|
||||
|
||||
// --- Round-trip tests: platform event -> JSON -> parse as control message ---
|
||||
|
||||
#[test]
|
||||
fn round_trip_offer() {
|
||||
let opaque = vec![0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(123u64),
|
||||
message: SignalingEvent::SendOffer {
|
||||
opaque: opaque.clone(),
|
||||
call_media_type: CallMediaType::Audio,
|
||||
},
|
||||
};
|
||||
let json_str = event.to_json();
|
||||
// The parent would receive this JSON and could extract the opaque
|
||||
let parsed: Value = serde_json::from_str(&json_str).unwrap();
|
||||
let decoded = BASE64
|
||||
.decode(parsed["opaque"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(decoded, opaque);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_ice() {
|
||||
let candidates = vec![vec![1, 2, 3], vec![4, 5, 6, 7, 8]];
|
||||
let event = PlatformEvent::SendSignaling {
|
||||
call_id: CallId::from(456u64),
|
||||
message: SignalingEvent::SendIce {
|
||||
candidates: candidates.clone(),
|
||||
},
|
||||
};
|
||||
let json_str = event.to_json();
|
||||
let parsed: Value = serde_json::from_str(&json_str).unwrap();
|
||||
let arr = parsed["candidates"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
for (i, c) in arr.iter().enumerate() {
|
||||
let decoded = BASE64.decode(c["opaque"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(decoded, candidates[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements SignalingSender -- relays signaling messages to the control channel.
|
||||
pub struct TunnelSignalingSender {
|
||||
pub event_sender: mpsc::Sender<PlatformEvent>,
|
||||
}
|
||||
|
||||
impl SignalingSender for TunnelSignalingSender {
|
||||
fn send_signaling(
|
||||
&self,
|
||||
_recipient_id: &str,
|
||||
call_id: CallId,
|
||||
_receiver_device_id: Option<DeviceId>,
|
||||
message: signaling::Message,
|
||||
) -> Result<()> {
|
||||
let event = match message {
|
||||
signaling::Message::Offer(offer) => SignalingEvent::SendOffer {
|
||||
opaque: offer.opaque,
|
||||
call_media_type: offer.call_media_type,
|
||||
},
|
||||
signaling::Message::Answer(answer) => SignalingEvent::SendAnswer {
|
||||
opaque: answer.opaque,
|
||||
},
|
||||
signaling::Message::Ice(ice) => SignalingEvent::SendIce {
|
||||
candidates: ice.candidates.into_iter().map(|c| c.opaque).collect(),
|
||||
},
|
||||
signaling::Message::Hangup(hangup) => {
|
||||
let (hangup_type, _device_id) = hangup.to_type_and_device_id();
|
||||
SignalingEvent::SendHangup {
|
||||
hangup_type: format!("{:?}", hangup_type).to_lowercase(),
|
||||
}
|
||||
}
|
||||
signaling::Message::Busy => SignalingEvent::SendBusy,
|
||||
};
|
||||
|
||||
if let Err(e) = self.event_sender.send(PlatformEvent::SendSignaling {
|
||||
call_id,
|
||||
message: event,
|
||||
}) {
|
||||
error!("Failed to send signaling event: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_call_message(
|
||||
&self,
|
||||
_recipient_id: UserId,
|
||||
_message: Vec<u8>,
|
||||
_urgency: group_call::SignalingMessageUrgency,
|
||||
) -> Result<()> {
|
||||
// No-op for 1:1 calls
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_call_message_to_group(
|
||||
&self,
|
||||
_group_id: group_call::GroupId,
|
||||
_message: Vec<u8>,
|
||||
_urgency: group_call::SignalingMessageUrgency,
|
||||
_recipients_override: HashSet<UserId>,
|
||||
) -> Result<()> {
|
||||
// No-op for 1:1 calls
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_call_message_to_adhoc_group(
|
||||
&self,
|
||||
_message: Vec<u8>,
|
||||
_urgency: group_call::SignalingMessageUrgency,
|
||||
_expiration: u64,
|
||||
_recipients_to_endorsements: HashMap<UserId, Vec<u8>>,
|
||||
) -> Result<()> {
|
||||
// No-op for 1:1 calls
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements CallStateHandler -- relays state changes to the control channel.
|
||||
pub struct TunnelStateHandler {
|
||||
pub event_sender: mpsc::Sender<PlatformEvent>,
|
||||
}
|
||||
|
||||
impl CallStateHandler for TunnelStateHandler {
|
||||
fn handle_call_state(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
_call_id: CallId,
|
||||
call_state: CallState,
|
||||
) -> Result<()> {
|
||||
let (state, reason) = match call_state {
|
||||
CallState::Incoming(media_type) => {
|
||||
(format!("Incoming({:?})", media_type), None)
|
||||
}
|
||||
CallState::Outgoing(media_type) => {
|
||||
(format!("Outgoing({:?})", media_type), None)
|
||||
}
|
||||
CallState::Ringing => ("Ringing".to_string(), None),
|
||||
CallState::Connected => ("Connected".to_string(), None),
|
||||
CallState::Connecting => ("Connecting".to_string(), None),
|
||||
CallState::Ended(reason, _summary) => {
|
||||
("Ended".to_string(), Some(format!("{:?}", reason)))
|
||||
}
|
||||
CallState::Rejected(reason) => {
|
||||
("Rejected".to_string(), Some(format!("{:?}", reason)))
|
||||
}
|
||||
CallState::Concluded => ("Concluded".to_string(), None),
|
||||
};
|
||||
|
||||
info!("Call state: {} (reason: {:?})", state, reason);
|
||||
|
||||
if let Err(e) = self
|
||||
.event_sender
|
||||
.send(PlatformEvent::StateChange { state, reason })
|
||||
{
|
||||
error!("Failed to send state change event: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_remote_audio_state(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
debug!("Remote audio state: {}", enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_remote_video_state(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
debug!("Remote video state: {}", enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_remote_sharing_screen(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
debug!("Remote sharing screen: {}", enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_network_route(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
network_route: NetworkRoute,
|
||||
) -> Result<()> {
|
||||
info!("Network route: {:?}", network_route);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_audio_levels(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
_captured_level: AudioLevel,
|
||||
_received_level: AudioLevel,
|
||||
) -> Result<()> {
|
||||
// Don't log -- too noisy
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_low_bandwidth_for_video(
|
||||
&self,
|
||||
_remote_peer_id: &str,
|
||||
recovered: bool,
|
||||
) -> Result<()> {
|
||||
if recovered {
|
||||
info!("Low bandwidth for video: recovered");
|
||||
} else {
|
||||
warn!("Low bandwidth for video");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements GroupUpdateHandler -- all no-ops for 1:1 calls.
|
||||
pub struct TunnelGroupHandler;
|
||||
|
||||
impl GroupUpdateHandler for TunnelGroupHandler {
|
||||
fn handle_group_update(&self, _update: GroupUpdate) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
third-party/ringrtc
vendored
Submodule
1
third-party/ringrtc
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit a86f8a6832cec291ef4f77e4ee9b941e5b91a01f
|
||||
Loading…
x
Reference in New Issue
Block a user