feat(network): implement Phase 6 LAN discovery and session signaling
Add mDNS/DNS-SD discovery and JSON session negotiation so two peers on a LAN connect without hard-coded addresses. - Discovery (Avahi threaded-poll client): the receiver announces _screencast._tcp with its signaling port; senders browse and resolve peers. Strict lock ordering (poll lock before state mutex) keeps the callbacks deadlock-free; name collisions rename via avahi_alternative_service_name. - Signaling: one JSON object per newline-terminated TCP line. The receiver hosts a server (port 5005) and answers session offers with its RTP port; senders connect, offer, and stream to the negotiated endpoint. WebSocket was deferred: no WS library is installed, the skill permits plain TCP, and the wire format is transport-agnostic. - Dual-stack transports: this machine resolves its own services over IPv6, so getaddrinfo now runs AF_UNSPEC and listeners bind IPv6 with IPV6_V6ONLY=0 (IPv4 fallback), covering UDP and TCP alike. The signaling client shutdown now uses shutdown() so a reader blocked in recv() cannot hang the join (a plain close() does not wake it). - CLI: --discover lists receivers (deduped to one entry per host); --send auto-disovers when exactly one receiver is found; --peer targets a receiver directly; --signaling-port overrides the default. Validation: meson test 5/5 (new signaling round-trip test), valgrind clean. End-to-end over loopback: --discover finds the announced receiver, a probe negotiated a session and streamed 60 frames over both IPv4 and IPv6, and the receiver reported stream started. mDNS resolution was verified against avahi-browse as an independent reference.
This commit is contained in:
+31
-1
@@ -9,7 +9,11 @@ namespace {
|
||||
|
||||
void print_usage() {
|
||||
std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n"
|
||||
" screencast --receive [--port PORT]\n",
|
||||
" screencast --receive [--port PORT] [--signaling-port PORT]\n"
|
||||
" screencast --discover [--timeout SECONDS]\n"
|
||||
"\n"
|
||||
"--send without --peer discovers a receiver on the LAN and requires\n"
|
||||
"that exactly one is found.\n",
|
||||
stderr);
|
||||
}
|
||||
|
||||
@@ -34,11 +38,13 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
|
||||
None,
|
||||
Send,
|
||||
Receive,
|
||||
Discover,
|
||||
};
|
||||
|
||||
Mode mode = Mode::None;
|
||||
SendCommand send;
|
||||
ReceiveCommand receive;
|
||||
DiscoverCommand discover;
|
||||
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string_view argument = argv[index];
|
||||
@@ -55,6 +61,12 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
|
||||
return std::nullopt;
|
||||
}
|
||||
mode = Mode::Receive;
|
||||
} else if (argument == "--discover") {
|
||||
if (mode != Mode::None) {
|
||||
print_usage();
|
||||
return std::nullopt;
|
||||
}
|
||||
mode = Mode::Discover;
|
||||
} else if (argument == "--target") {
|
||||
std::string_view value;
|
||||
if (!next_argument(argc, argv, index, value)) {
|
||||
@@ -90,6 +102,21 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
|
||||
return std::nullopt;
|
||||
}
|
||||
receive.local_rtp_port = port;
|
||||
} else if (argument == "--signaling-port") {
|
||||
std::string_view value;
|
||||
int port = 0;
|
||||
if (!next_argument(argc, argv, index, value) || !parse_int(value, port) || port <= 0 || port > 65535) {
|
||||
print_usage();
|
||||
return std::nullopt;
|
||||
}
|
||||
receive.signaling_port = port;
|
||||
} else if (argument == "--timeout") {
|
||||
std::string_view value;
|
||||
if (!next_argument(argc, argv, index, value) || !parse_int(value, discover.timeout_seconds) ||
|
||||
discover.timeout_seconds <= 0) {
|
||||
print_usage();
|
||||
return std::nullopt;
|
||||
}
|
||||
} else {
|
||||
print_usage();
|
||||
return std::nullopt;
|
||||
@@ -102,6 +129,9 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
|
||||
if (mode == Mode::Receive) {
|
||||
return Command{std::move(receive)};
|
||||
}
|
||||
if (mode == Mode::Discover) {
|
||||
return Command{std::move(discover)};
|
||||
}
|
||||
print_usage();
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
+175
-6
@@ -1,22 +1,37 @@
|
||||
#include "screencast/app/cli.h"
|
||||
#include "screencast/app/pipeline.h"
|
||||
|
||||
#include "screencast/network/discovery.h"
|
||||
#include "screencast/network/signaling.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <format>
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::uint16_t kDefaultSignalingPort = 5005;
|
||||
constexpr int kSenderDiscoveryTimeoutSeconds = 3;
|
||||
|
||||
std::atomic<bool> g_interrupted{false};
|
||||
|
||||
void handle_interrupt(int) {
|
||||
g_interrupted.store(true);
|
||||
}
|
||||
|
||||
sc::Endpoint parse_endpoint(std::string_view address, uint16_t default_port) {
|
||||
sc::Endpoint parse_endpoint(std::string_view address, std::uint16_t default_port) {
|
||||
sc::Endpoint endpoint;
|
||||
const std::size_t separator = address.rfind(':');
|
||||
if (separator != std::string_view::npos) {
|
||||
@@ -24,7 +39,7 @@ sc::Endpoint parse_endpoint(std::string_view address, uint16_t default_port) {
|
||||
int port = 0;
|
||||
const auto [pointer, error] =
|
||||
std::from_chars(address.data() + separator + 1, address.data() + address.size(), port);
|
||||
endpoint.port = error == std::errc{} ? static_cast<uint16_t>(port) : default_port;
|
||||
endpoint.port = error == std::errc{} ? static_cast<std::uint16_t>(port) : default_port;
|
||||
} else {
|
||||
endpoint.address = std::string{address};
|
||||
endpoint.port = default_port;
|
||||
@@ -32,17 +47,150 @@ sc::Endpoint parse_endpoint(std::string_view address, uint16_t default_port) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
std::string make_session_id() {
|
||||
static std::mt19937 engine{std::random_device{}()};
|
||||
std::string id;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
id += "0123456789abcdef"[static_cast<std::size_t>(engine()) & 0xF];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
std::vector<sc::DiscoveredPeer> discover_peers(int timeout_seconds, std::string& error) {
|
||||
std::vector<sc::DiscoveredPeer> peers;
|
||||
std::mutex mutex;
|
||||
|
||||
auto discovery_result = sc::DiscoveryFactory::create_avahi();
|
||||
if (sc::is_network_error(discovery_result)) {
|
||||
error = sc::network_error(discovery_result).message;
|
||||
return peers;
|
||||
}
|
||||
auto discovery = std::move(sc::network_value(discovery_result));
|
||||
|
||||
if (!discovery->browse([&](const sc::DiscoveredPeer& peer) {
|
||||
std::lock_guard lock(mutex);
|
||||
// One host with many interfaces resolves to several addresses;
|
||||
// a single entry per (name, port) keeps discovery readable and
|
||||
// lets senders auto-pick without ambiguity.
|
||||
const bool known = std::any_of(peers.begin(), peers.end(), [&peer](const sc::DiscoveredPeer& existing) {
|
||||
return existing.service_name == peer.service_name && existing.signaling_port == peer.signaling_port;
|
||||
});
|
||||
if (!known) {
|
||||
peers.push_back(peer);
|
||||
}
|
||||
})) {
|
||||
error = discovery->last_error();
|
||||
discovery->stop();
|
||||
return peers;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(timeout_seconds));
|
||||
if (peers.empty()) {
|
||||
// Surface a silent resolver failure (e.g. avahi daemon problems)
|
||||
// instead of an empty result without explanation.
|
||||
const std::string last_error = discovery->last_error();
|
||||
if (!last_error.empty()) {
|
||||
error = last_error;
|
||||
}
|
||||
}
|
||||
discovery->stop();
|
||||
return peers;
|
||||
}
|
||||
|
||||
int run_sender(const sc::SendCommand& command) {
|
||||
// 1. Find the receiver's signaling endpoint: explicit --peer, or discover
|
||||
// exactly one receiver on the LAN.
|
||||
sc::Endpoint signaling;
|
||||
if (!command.peer_address.empty()) {
|
||||
signaling = parse_endpoint(command.peer_address, kDefaultSignalingPort);
|
||||
} else {
|
||||
std::string error;
|
||||
const std::vector<sc::DiscoveredPeer> peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error);
|
||||
if (!error.empty()) {
|
||||
std::cerr << std::format("screencast: discovery failed: {}\n", error);
|
||||
return 1;
|
||||
}
|
||||
if (peers.empty()) {
|
||||
std::cerr << "screencast: no receiver found on the LAN; run 'screencast --receive' on the "
|
||||
"target machine, or pass --peer\n";
|
||||
return 1;
|
||||
}
|
||||
if (peers.size() > 1) {
|
||||
for (const sc::DiscoveredPeer& peer : peers) {
|
||||
std::cerr << std::format(
|
||||
"screencast: {} at {}:{}\n", peer.service_name, peer.host, peer.signaling_port);
|
||||
}
|
||||
std::cerr << "screencast: multiple receivers found; pass --peer to choose one\n";
|
||||
return 1;
|
||||
}
|
||||
signaling = sc::Endpoint{peers.front().host, peers.front().signaling_port};
|
||||
std::cout << std::format("screencast: found receiver '{}'\n", peers.front().service_name);
|
||||
}
|
||||
|
||||
// 2. Negotiate a session over the signaling channel.
|
||||
auto channel_result = sc::SignalingFactory::create_client();
|
||||
if (sc::is_network_error(channel_result)) {
|
||||
std::cerr << std::format("screencast: {}\n", sc::network_error(channel_result).message);
|
||||
return 1;
|
||||
}
|
||||
auto channel = std::move(sc::network_value(channel_result));
|
||||
if (!channel->connect(signaling)) {
|
||||
std::cerr << std::format(
|
||||
"screencast: failed to connect to the receiver at {}:{}\n", signaling.address, signaling.port);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::promise<sc::SessionAnswer> answer_promise;
|
||||
auto answer_future = answer_promise.get_future();
|
||||
std::atomic<bool> answered{false};
|
||||
channel->on_message([&](const sc::SignalingMessage& message) {
|
||||
if (const sc::SessionAnswer* answer = std::get_if<sc::SessionAnswer>(&message)) {
|
||||
if (!answered.exchange(true)) {
|
||||
answer_promise.set_value(*answer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sc::SessionOffer offer;
|
||||
offer.session_id = make_session_id();
|
||||
offer.codec_name = "h264";
|
||||
offer.frame_rate_num = 25;
|
||||
offer.frame_rate_den = 1;
|
||||
channel->send(offer);
|
||||
|
||||
if (answer_future.wait_for(std::chrono::seconds(5)) != std::future_status::ready) {
|
||||
std::cerr << "screencast: the receiver did not answer the session offer\n";
|
||||
channel->disconnect();
|
||||
return 1;
|
||||
}
|
||||
const sc::SessionAnswer answer = answer_future.get();
|
||||
channel->disconnect();
|
||||
|
||||
if (answer.session_id != offer.session_id) {
|
||||
std::cerr << "screencast: session mismatch in the receiver's answer\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 3. Stream to the negotiated RTP endpoint. An empty address means
|
||||
// "the address you reached me on".
|
||||
const sc::Endpoint rtp_endpoint = answer.rtp_endpoint.address.empty()
|
||||
? sc::Endpoint{signaling.address, answer.rtp_endpoint.port}
|
||||
: answer.rtp_endpoint;
|
||||
|
||||
sc::SenderPipelineConfig config;
|
||||
if (command.target == "window") {
|
||||
config.capture_target = sc::CaptureTargetWindow{};
|
||||
} else {
|
||||
config.capture_target = sc::CaptureTargetWholeScreen{};
|
||||
}
|
||||
config.peer_rtp_endpoint =
|
||||
command.peer_address.empty() ? sc::Endpoint{"127.0.0.1", 5004} : parse_endpoint(command.peer_address, 5004);
|
||||
config.peer_rtp_endpoint = rtp_endpoint;
|
||||
config.encoder.bitrate_kbps = command.bitrate_kbps;
|
||||
|
||||
std::cout << std::format("screencast: session {} established; streaming to {}:{}\n",
|
||||
offer.session_id,
|
||||
rtp_endpoint.address,
|
||||
rtp_endpoint.port);
|
||||
|
||||
sc::SenderPipeline pipeline{std::move(config)};
|
||||
if (!pipeline.start()) {
|
||||
return 1;
|
||||
@@ -56,7 +204,8 @@ int run_sender(const sc::SendCommand& command) {
|
||||
|
||||
int run_receiver(const sc::ReceiveCommand& command) {
|
||||
sc::ReceiverPipelineConfig config;
|
||||
config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast<uint16_t>(command.local_rtp_port)};
|
||||
config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast<std::uint16_t>(command.local_rtp_port)};
|
||||
config.signaling_port = static_cast<std::uint16_t>(command.signaling_port);
|
||||
|
||||
sc::ReceiverPipeline pipeline{std::move(config)};
|
||||
if (!pipeline.start()) {
|
||||
@@ -69,6 +218,23 @@ int run_receiver(const sc::ReceiveCommand& command) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int run_discover(const sc::DiscoverCommand& command) {
|
||||
std::string error;
|
||||
const std::vector<sc::DiscoveredPeer> peers = discover_peers(command.timeout_seconds, error);
|
||||
if (!error.empty()) {
|
||||
std::cerr << std::format("screencast: discovery failed: {}\n", error);
|
||||
return 1;
|
||||
}
|
||||
if (peers.empty()) {
|
||||
std::cout << "no screencast receivers found\n";
|
||||
return 0;
|
||||
}
|
||||
for (const sc::DiscoveredPeer& peer : peers) {
|
||||
std::cout << std::format("{} at {}:{}\n", peer.service_name, peer.host, peer.signaling_port);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
@@ -83,5 +249,8 @@ int main(int argc, char* argv[]) {
|
||||
if (const sc::SendCommand* send = std::get_if<sc::SendCommand>(&*command)) {
|
||||
return run_sender(*send);
|
||||
}
|
||||
return run_receiver(std::get<sc::ReceiveCommand>(*command));
|
||||
if (const sc::ReceiveCommand* receive = std::get_if<sc::ReceiveCommand>(&*command)) {
|
||||
return run_receiver(*receive);
|
||||
}
|
||||
return run_discover(std::get<sc::DiscoverCommand>(*command));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#include "screencast/app/pipeline.h"
|
||||
|
||||
#include "screencast/network/discovery.h"
|
||||
#include "screencast/network/h264_packetizer.h"
|
||||
#include "screencast/network/signaling.h"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
@@ -11,6 +16,17 @@
|
||||
#include <thread>
|
||||
|
||||
namespace sc {
|
||||
namespace {
|
||||
|
||||
std::string receiver_service_name() {
|
||||
std::array<char, 256> hostname{};
|
||||
if (::gethostname(hostname.data(), hostname.size()) != 0 || hostname[0] == '\0') {
|
||||
return "Screencast receiver";
|
||||
}
|
||||
return std::string{"Screencast receiver on "} + hostname.data();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class SenderPipeline::Impl {
|
||||
public:
|
||||
@@ -127,6 +143,29 @@ class ReceiverPipeline::Impl {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Signaling server: answer session offers with our RTP port so a
|
||||
// sender can find the media endpoint without configuration.
|
||||
auto signaling_result = SignalingFactory::create_server(config_.signaling_port);
|
||||
if (is_network_error(signaling_result)) {
|
||||
std::cerr << std::format("screencast: {}\n", network_error(signaling_result).message);
|
||||
transport_->stop();
|
||||
return false;
|
||||
}
|
||||
signaling_ = std::move(network_value(signaling_result));
|
||||
signaling_->on_message([this](const SignalingMessage& message) { handle_signaling(message); });
|
||||
|
||||
// mDNS announcement; best effort, since senders can still use --peer.
|
||||
auto discovery_result = DiscoveryFactory::create_avahi();
|
||||
if (is_network_error(discovery_result)) {
|
||||
std::cerr << std::format("screencast: discovery unavailable: {}\n",
|
||||
network_error(discovery_result).message);
|
||||
} else {
|
||||
discovery_ = std::move(network_value(discovery_result));
|
||||
if (!discovery_->announce(receiver_service_name(), config_.signaling_port)) {
|
||||
std::cerr << std::format("screencast: announcing the receiver failed: {}\n", discovery_->last_error());
|
||||
}
|
||||
}
|
||||
|
||||
// Every SDL call — window creation, event pumping, presenting, and
|
||||
// destruction — happens on the render thread. The Wayland backend
|
||||
// does not tolerate cross-thread windows: created on another thread,
|
||||
@@ -163,11 +202,19 @@ class ReceiverPipeline::Impl {
|
||||
|
||||
std::unique_lock lock(init_mutex);
|
||||
if (!init_done_cv.wait_for(lock, std::chrono::seconds(10), [&init_done] { return init_done; })) {
|
||||
if (discovery_ != nullptr) {
|
||||
discovery_->stop();
|
||||
}
|
||||
signaling_ = nullptr;
|
||||
transport_->stop();
|
||||
return false;
|
||||
}
|
||||
if (!init_ok) {
|
||||
render_thread_ = std::jthread{};
|
||||
if (discovery_ != nullptr) {
|
||||
discovery_->stop();
|
||||
}
|
||||
signaling_ = nullptr;
|
||||
transport_->stop();
|
||||
return false;
|
||||
}
|
||||
@@ -176,6 +223,16 @@ class ReceiverPipeline::Impl {
|
||||
|
||||
void stop() {
|
||||
render_thread_ = std::jthread{};
|
||||
// Join the signaling threads before dropping the channel so no
|
||||
// callback races the destruction.
|
||||
if (signaling_ != nullptr) {
|
||||
signaling_->disconnect();
|
||||
}
|
||||
if (discovery_ != nullptr) {
|
||||
discovery_->stop();
|
||||
}
|
||||
signaling_ = nullptr;
|
||||
discovery_ = nullptr;
|
||||
transport_->stop();
|
||||
renderer_ = nullptr;
|
||||
decoder_ = nullptr;
|
||||
@@ -216,6 +273,19 @@ class ReceiverPipeline::Impl {
|
||||
}
|
||||
}
|
||||
|
||||
void handle_signaling(const SignalingMessage& message) {
|
||||
const SessionOffer* offer = std::get_if<SessionOffer>(&message);
|
||||
if (offer == nullptr) {
|
||||
return;
|
||||
}
|
||||
SessionAnswer answer;
|
||||
answer.session_id = offer->session_id;
|
||||
// Empty address: the sender targets the address of its signaling
|
||||
// connection, which reaches this RTP port.
|
||||
answer.rtp_endpoint = Endpoint{"", config_.local_rtp_endpoint.port};
|
||||
signaling_->send(answer);
|
||||
}
|
||||
|
||||
void render_loop(std::stop_token stop_token) {
|
||||
while (!stop_token.stop_requested()) {
|
||||
if (!renderer_->poll_events()) {
|
||||
@@ -247,6 +317,8 @@ class ReceiverPipeline::Impl {
|
||||
std::unique_ptr<Decoder> decoder_;
|
||||
H264Depacketizer depacketizer_;
|
||||
std::unique_ptr<RtpTransport> transport_ = RtpTransportFactory::create();
|
||||
std::unique_ptr<SignalingChannel> signaling_;
|
||||
std::unique_ptr<DiscoveryService> discovery_;
|
||||
std::jthread render_thread_;
|
||||
std::mutex queue_mutex_;
|
||||
std::deque<DecodedFrame> queue_;
|
||||
|
||||
Reference in New Issue
Block a user