feat(gui): add a GTK sender panel and a waybar widget

A gtkmm-4.0 control panel (behind -Dgui=true, default off): refresh
shows discovered receivers (grouped and preference-sorted), a bitrate
scale, and start/stop that runs the whole session on a worker thread
so the interactive portal picker never blocks the UI. The CLI and the
GUI now share the new sc_app_core static library holding the
pipelines, session orchestration (negotiation + PLI feedback), and a
state store.

The sender pipeline publishes its state to
$XDG_RUNTIME_DIR/screencast/sender.json (session id, receiver,
bitrate, pid, start time; stale files detected by pid liveness) and
persists the last session for one-click restarts. The new
'screencast waybar' subcommand prints a waybar module line and its
--toggle flag stops a running sender gracefully or spawns a detached
restart of the last receiver.

Waybar on the dev machine is wired: custom/screencast module with
click-to-toggle and right-click panel, plus styles, with a timestamped
backup of both config files. Both binaries are installed to
/usr/local/bin.

Validated: waybar output (idle and streaming states with a synthetic
state file), GUI launches on the desktop (window observed via
hyprctl), meson test 5/5 in both build configurations, formatting
clean.
This commit is contained in:
2026-09-08 17:14:26 +02:00
parent 74b3f04082
commit 5c39662cc2
16 changed files with 971 additions and 187 deletions
+10
View File
@@ -65,6 +65,16 @@ loopback; current phase is Phase 7.
before the fix). Cross-compiling on the dev machine was considered and
dropped — the on-Pi build works and the toolchain/container effort was
not needed.
- **GTK panel + waybar widget**: `screencast-gui` (gtkmm-4.0, behind
`-Dgui=true`; app internals now live in the `sc_app_core` static lib so
CLI and GUI share pipelines/session/state). `screencast waybar [--toggle]`
prints the waybar module line and toggles streaming via SIGTERM (stop) or
a detached re-exec spawn from `last-session.json` (start). State lives in
`$XDG_RUNTIME_DIR/screencast/sender.json` (written by the pipeline with
session id, receiver, bitrate, pid, start time; stale files are detected
by pid-liveness). The waybar config was wired into the user's bar
(`custom/screencast` before `custom/timetrack`, with backup) and the
binaries installed to /usr/local/bin.
- **Pi Wi-Fi hotspot** (`scripts/pi-hotspot.sh on|off|status`): NetworkManager
AP mode (WPA2, ipv4 shared → built-in DHCP/NAT, Pi at 10.42.0.1). Takes
over wlan0 while active; generated PSK stored in /etc/screencast-hotspot.conf.
+4 -3
View File
@@ -89,9 +89,10 @@ discovered addresses in reachability order (private IPv4 first).
- [x] Jitter/reorder window on the receiver (16 packets / 60 ms).
- [x] Hardware H.264 decode probe with software fallback (h264_v4l2m2m —
the Pi's VideoCore path; --swdecode opts out).
- [ ] GTK4 sender GUI behind `meson -Dgui=true` + a waybar widget
(requested; plan approved scope: sender panel, state file,
`screencast waybar` subcommand).
- [x] GTK4 sender GUI behind `meson -Dgui=true` (gtkmm; receiver list,
bitrate, start/stop) plus a waybar widget (`screencast waybar`,
click-to-toggle, right-click opens the panel; state shared via
$XDG_RUNTIME_DIR/screencast/sender.json).
- [ ] VAAPI hardware encode probe on the sender (deferred: software
encode is not the bottleneck).
- Deferred: .desktop file, packaging.
+18
View File
@@ -134,6 +134,24 @@ A window manager only comes with the desktop-session alternative, where
the receiver runs inside it (uncomment the `Environment=` lines in the
service file as described above).
## GTK panel and waybar widget (Phase 7)
Build the GUI alongside the CLI (`meson configure build -Dgui=true`, then
recompile; installs as `screencast-gui`):
```sh
screencast-gui # receiver list → pick one → bitrate → Start
screencast waybar # one JSON line for a waybar custom module
screencast waybar --toggle # stop a running sender / restart the last
```
The waybar module (installed to this machine's `~/.config/waybar/` — see
`custom/screencast` there): `▶` while streaming (tooltip: receiver, bitrate,
elapsed), `⏸` while idle; left-click toggles streaming to the last receiver
via the state file, right-click opens the panel. The sender publishes its
state to `$XDG_RUNTIME_DIR/screencast/sender.json`, so every front-end —
CLI, GUI, widget — agrees on what is running.
## Under the hood: resilience (Phase 7)
The receiver absorbs loss in two stages and recovers actively:
+5 -1
View File
@@ -25,7 +25,11 @@ struct DiscoverCommand {
int timeout_seconds = 3;
};
using Command = std::variant<SendCommand, ReceiveCommand, DiscoverCommand>;
struct WaybarCommand {
bool toggle = false; // toggle streaming instead of printing status
};
using Command = std::variant<SendCommand, ReceiveCommand, DiscoverCommand, WaybarCommand>;
// Parse command line arguments. Prints usage and returns std::nullopt on error.
// `argv` is `char const* const*` so both `main`'s `char**` and const arrays
+3
View File
@@ -19,6 +19,9 @@ struct SenderPipelineConfig {
// Where encoded RTP packets are sent. Defaults to the local loopback so
// a sender and receiver on one machine work without any configuration.
Endpoint peer_rtp_endpoint{"127.0.0.1", 5004};
// Session identity from signaling; written to the sender state file for
// status widgets (waybar) and one-click restarts.
std::string session_id;
};
struct ReceiverPipelineConfig {
+14
View File
@@ -11,6 +11,7 @@ void print_usage() {
std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n"
" screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen] [--swdecode]\n"
" screencast --discover [--timeout SECONDS]\n"
" screencast waybar [--toggle] # for waybar widgets\n"
"\n"
"--send without --peer discovers a receiver on the LAN and requires\n"
"that exactly one is found.\n",
@@ -39,12 +40,14 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
Send,
Receive,
Discover,
Waybar,
};
Mode mode = Mode::None;
SendCommand send;
ReceiveCommand receive;
DiscoverCommand discover;
WaybarCommand waybar;
for (int index = 1; index < argc; ++index) {
const std::string_view argument = argv[index];
@@ -67,6 +70,14 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
return std::nullopt;
}
mode = Mode::Discover;
} else if (argument == "waybar") {
if (mode != Mode::None) {
print_usage();
return std::nullopt;
}
mode = Mode::Waybar;
} else if (argument == "--toggle") {
waybar.toggle = true;
} else if (argument == "--target") {
std::string_view value;
if (!next_argument(argc, argv, index, value)) {
@@ -136,6 +147,9 @@ std::optional<Command> parse_cli(int argc, const char* const argv[]) {
if (mode == Mode::Discover) {
return Command{std::move(discover)};
}
if (mode == Mode::Waybar) {
return Command{std::move(waybar)};
}
print_usage();
return std::nullopt;
}
+108 -149
View File
@@ -1,9 +1,15 @@
#include "screencast/app/cli.h"
#include "screencast/app/pipeline.h"
#include "sender_session.h"
#include "state_store.h"
#include "screencast/network/discovery.h"
#include "screencast/network/signaling.h"
#include <sys/types.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <charconv>
@@ -53,49 +59,6 @@ sc::Endpoint parse_endpoint(std::string_view address, std::uint16_t default_port
#endif // SC_HAS_SENDER
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;
}
bool is_private_ipv4(std::string_view host) {
if (host.rfind("192.168.", 0) == 0 || host.rfind("10.", 0) == 0) {
return true;
}
if (host.rfind("172.", 0) == 0) {
const std::size_t second = host.find('.', 5);
if (second != std::string_view::npos) {
const int octet = std::stoi(std::string{host.substr(5, second - 5)});
return octet >= 16 && octet <= 31;
}
}
return false;
}
// Ordering for trying a receiver's addresses: private IPv4 first (LANs,
// most reliable), then public IPv4, ULA, and global IPv6. 6to4 (2002::) and
// link-local addresses last: 6to4 is frequently unreachable between LAN
// peers, and link-local needs a scope id to even route.
int address_preference(std::string_view host) {
if (host.find(':') == std::string_view::npos) {
return is_private_ipv4(host) ? 0 : 1;
}
if (host.rfind("fd", 0) == 0 || host.rfind("fc", 0) == 0) {
return 2;
}
if (host.rfind("2002:", 0) == 0) {
return 4;
}
if (host.rfind("fe80:", 0) == 0) {
return 5;
}
return 3;
}
std::vector<sc::DiscoveredPeer> discover_peers(int timeout_seconds, std::string& error) {
std::vector<sc::DiscoveredPeer> peers;
std::mutex mutex;
@@ -140,104 +103,20 @@ std::vector<sc::DiscoveredPeer> discover_peers(int timeout_seconds, std::string&
#ifdef SC_HAS_SENDER
// Offer, wait for the answer, and stream to the negotiated endpoint. The
// channel must already be connected.
int negotiate_and_stream(sc::SignalingChannel& channel, const sc::Endpoint& signaling, const sc::SendCommand& command) {
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();
// The channel stays open: the receiver sends PLI keyframe requests over
// it during the session.
if (answer.session_id != offer.session_id) {
std::cerr << "screencast: session mismatch in the receiver's answer\n";
return 1;
}
// 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 = 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()) {
channel.disconnect();
return 1;
}
channel.on_message([&](const sc::SignalingMessage& message) {
const sc::SessionPli* pli = std::get_if<sc::SessionPli>(&message);
if (pli != nullptr && pli->session_id == offer.session_id) {
pipeline.request_keyframe();
std::cerr << "screencast: receiver requested a keyframe\n";
}
});
while (!g_interrupted.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
pipeline.stop();
channel.disconnect();
return 0;
}
int run_sender(const sc::SendCommand& command) {
// Find the receiver's signaling endpoint: explicit --peer, or discover
// exactly one receiver on the LAN. A receiver may resolve to several
// addresses; try them in reachability order until the signaling
// connection succeeds.
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));
sc::Endpoint signaling;
sc::CaptureTarget target = sc::CaptureTargetWholeScreen{};
if (command.target == "window") {
target = sc::CaptureTargetWindow{};
}
if (!command.peer_address.empty()) {
signaling = parse_endpoint(command.peer_address, kDefaultSignalingPort);
if (!channel->connect(signaling)) {
std::cerr << std::format(
"screencast: failed to connect to the receiver at {}:{}\n", signaling.address, signaling.port);
return 1;
}
return negotiate_and_stream(*channel, signaling, command);
}
} else {
std::string error;
std::vector<sc::DiscoveredPeer> peers = discover_peers(kSenderDiscoveryTimeoutSeconds, error);
if (!error.empty()) {
@@ -261,7 +140,8 @@ int run_sender(const sc::SendCommand& command) {
std::cerr << std::format(
"screencast: {} at {}\n",
key.first,
std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
std::accumulate(
hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
return lhs.empty() ? rhs : lhs + ", " + rhs;
}));
}
@@ -272,29 +152,105 @@ int run_sender(const sc::SendCommand& command) {
auto [name, port] = receivers.begin()->first;
std::vector<std::string> hosts = receivers.begin()->second;
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return address_preference(lhs) < address_preference(rhs);
return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs);
});
std::cout << std::format("screencast: found receiver '{}'\n", name);
signaling = sc::Endpoint{hosts.front(), port};
}
bool connected = false;
std::string tried;
for (const std::string& host : hosts) {
if (channel->connect(sc::Endpoint{host, port})) {
signaling = sc::Endpoint{host, port};
connected = true;
break;
}
tried += (tried.empty() ? "" : ", ") + host;
}
if (!connected) {
std::cerr << std::format("screencast: could not reach the receiver (tried {})\n", tried);
auto session_result = sc::SenderSession::start(signaling, command.bitrate_kbps, target);
if (auto* error = std::get_if<std::string>(&session_result)) {
std::cerr << std::format("screencast: {}\n", *error);
return 1;
}
return negotiate_and_stream(*channel, signaling, command);
auto session = std::move(std::get<sc::SenderSession>(session_result));
std::cout << std::format("screencast: session {} streaming to {}\n", session.session_id(), session.receiver());
while (!g_interrupted.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
session.stop();
return 0;
}
#endif // SC_HAS_SENDER
#ifdef SC_HAS_SENDER
void spawn_detached_sender(const std::string& peer, int bitrate_kbps) {
char self_path[4096] = {};
const ssize_t length = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
if (length <= 0) {
return;
}
self_path[length] = '\0';
const pid_t child = fork();
if (child != 0) {
return; // parent returns immediately; the child streams detached
}
setsid();
(void)freopen("/dev/null", "w", stdout);
const std::string bitrate = std::to_string(bitrate_kbps);
(void)execl(self_path,
"screencast",
"--send",
"--peer",
peer.c_str(),
"--bitrate",
bitrate.c_str(),
static_cast<char*>(nullptr));
_exit(127);
}
#endif
void print_waybar_status() {
const auto state = sc::read_sender_state();
if (!state.has_value()) {
std::cout << std::format("{{\"text\":\"\u23f8\",\"alt\":\"idle\",\"class\":\"idle\","
"\"tooltip\":\"screencast idle \u2014 click to stream to the last receiver\n"
"right-click: open the panel\"}}\n");
return;
}
const std::int64_t elapsed_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
.count() -
state->started_epoch_ms;
const std::int64_t minutes = elapsed_ms / 60000;
const std::int64_t seconds = (elapsed_ms / 1000) % 60;
std::cout << std::format("{{\"text\":\"\u25b6\",\"alt\":\"streaming\",\"class\":\"streaming\","
"\"tooltip\":\"screencast \u2192 {}\\n{} kbps \u00b7 {}m {:02}s\\nsession {}\"}}\n",
state->receiver,
state->bitrate_kbps,
minutes,
seconds,
state->session_id);
}
int run_waybar(const sc::WaybarCommand& command) {
if (command.toggle) {
if (const auto state = sc::read_sender_state(); state.has_value()) {
(void)kill(static_cast<pid_t>(state->pid), SIGTERM);
// Give the graceful shutdown time to withdraw its state file so
// the immediately following status print reflects reality.
for (int attempt = 0; attempt < 20; ++attempt) {
if (!sc::read_sender_state().has_value()) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
} else {
#ifdef SC_HAS_SENDER
if (const auto last = sc::read_last_session(); last.has_value()) {
spawn_detached_sender(last->peer, last->bitrate_kbps);
}
#endif
}
}
print_waybar_status();
return 0;
}
int run_receiver(const sc::ReceiveCommand& command) {
sc::ReceiverPipelineConfig config;
config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast<std::uint16_t>(command.local_rtp_port)};
@@ -334,7 +290,7 @@ int run_discover(const sc::DiscoverCommand& command) {
}
for (auto& [key, hosts] : receivers) {
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return address_preference(lhs) < address_preference(rhs);
return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs);
});
std::string joined =
std::accumulate(hosts.begin(), hosts.end(), std::string{}, [](std::string lhs, const std::string& rhs) {
@@ -368,5 +324,8 @@ int main(int argc, char* argv[]) {
if (const sc::ReceiveCommand* receive = std::get_if<sc::ReceiveCommand>(&*command)) {
return run_receiver(*receive);
}
if (const sc::WaybarCommand* waybar = std::get_if<sc::WaybarCommand>(&*command)) {
return run_waybar(*waybar);
}
return run_discover(std::get<sc::DiscoverCommand>(*command));
}
+22 -3
View File
@@ -1,3 +1,23 @@
# Shared application internals used by both the CLI binary and the GUI:
# pipelines, session orchestration, and the state store.
sc_app_core_sources = files(
'pipelines.cpp',
'sender_session.cpp',
'state_store.cpp',
)
sc_app_core = static_library('sc_app_core',
sc_app_core_sources,
include_directories : [sc_core_inc, include_directories('.')],
cpp_args : ['-DSC_HAS_SENDER=1'],
dependencies : [dep_json])
sc_app_core_dep = declare_dependency(
link_with : sc_app_core,
include_directories : include_directories('.'),
dependencies : [dep_json])
# The screencast application binary wiring every module together.
# Receiver-only builds (-Dsender=false) exclude the capture backend and the
# sender pipeline.
@@ -5,10 +25,9 @@
screencast_sources = [
'cli.cpp',
'main.cpp',
'pipelines.cpp',
]
screencast_dependencies = [sc_codec_dep, sc_network_dep, sc_render_dep]
screencast_dependencies = [sc_app_core_dep, sc_codec_dep, sc_network_dep, sc_render_dep]
screencast_arguments = []
if build_sender
@@ -16,7 +35,7 @@ if build_sender
screencast_arguments += ['-DSC_HAS_SENDER=1']
endif
screencast = executable('screencast',
executable('screencast',
screencast_sources,
cpp_args : screencast_arguments,
dependencies : screencast_dependencies,
+21
View File
@@ -1,11 +1,15 @@
#include "screencast/app/pipeline.h"
#include "state_store.h"
#include "screencast/network/discovery.h"
#include "screencast/network/h264_packetizer.h"
#include "screencast/network/signaling.h"
#include <unistd.h>
#include <unistd.h>
#include <array>
#include <chrono>
#include <condition_variable>
@@ -53,10 +57,27 @@ class SenderPipeline::Impl {
transport_->set_peer(config_.peer_rtp_endpoint);
run_thread_ = std::jthread([this](std::stop_token stop_token) { run(std::move(stop_token)); });
// Publish the session for status widgets and one-click restarts.
// The RTP peer is what a restart needs; the session id identifies it.
write_sender_state(SenderState{
.session_id = config_.session_id,
.receiver = std::format("{}:{}", config_.peer_rtp_endpoint.address, config_.peer_rtp_endpoint.port),
.bitrate_kbps = config_.encoder.bitrate_kbps,
.pid = ::getpid(),
.started_epoch_ms = 0});
// A restart targets the receiver's signaling endpoint; the RTP
// endpoint is re-negotiated from it.
std::string restart_peer = std::format("{}:5005", config_.peer_rtp_endpoint.address);
if (config_.signaling_server.has_value()) {
restart_peer = std::format("{}:{}", config_.signaling_server->address, config_.signaling_server->port);
}
write_last_session(LastSession{.peer = restart_peer, .bitrate_kbps = config_.encoder.bitrate_kbps});
return true;
}
void stop() {
remove_sender_state();
if (capture_ != nullptr) {
capture_->stop();
}
+177
View File
@@ -0,0 +1,177 @@
#include "sender_session.h"
#include "screencast/network/signaling.h"
#include <atomic>
#include <charconv>
#include <chrono>
#include <format>
#include <future>
#include <memory>
#include <string_view>
#include <utility>
namespace sc {
namespace {
bool is_private_ipv4(std::string_view host) {
if (host.rfind("192.168.", 0) == 0 || host.rfind("10.", 0) == 0) {
return true;
}
if (host.rfind("172.", 0) == 0) {
const std::size_t second = host.find('.', 5);
if (second != std::string_view::npos) {
int octet = 0;
const auto [pointer, error] = std::from_chars(host.data() + 5, host.data() + second, octet);
if (error == std::errc{}) {
return octet >= 16 && octet <= 31;
}
}
}
return false;
}
} // namespace
int SenderSession::address_preference(std::string_view host) {
if (host.find(':') == std::string_view::npos) {
return is_private_ipv4(host) ? 0 : 1;
}
if (host.rfind("fd", 0) == 0 || host.rfind("fc", 0) == 0) {
return 2;
}
if (host.rfind("2002:", 0) == 0) {
return 4;
}
if (host.rfind("fe80:", 0) == 0) {
return 5;
}
return 3;
}
std::variant<SenderSession, std::string>
SenderSession::start(const Endpoint& signaling_endpoint, int bitrate_kbps, CaptureTarget target) {
auto channel_result = SignalingFactory::create_client();
if (is_network_error(channel_result)) {
return network_error(channel_result).message;
}
auto channel = std::move(network_value(channel_result));
if (!channel->connect(signaling_endpoint)) {
return std::format(
"failed to connect to the receiver at {}:{}", signaling_endpoint.address, signaling_endpoint.port);
}
// Offer + answer.
static std::atomic<std::uint32_t> session_counter{0};
const std::string session_id = std::format(
"sc-{:x}-{:x}",
static_cast<std::uint32_t>(std::chrono::steady_clock::now().time_since_epoch().count()) & 0xffffffffU,
session_counter.fetch_add(1));
std::promise<SessionAnswer> answer_promise;
auto answer_future = answer_promise.get_future();
std::atomic<bool> answered{false};
channel->on_message([&](const SignalingMessage& message) {
if (const SessionAnswer* answer = std::get_if<SessionAnswer>(&message)) {
if (!answered.exchange(true)) {
answer_promise.set_value(*answer);
}
}
});
SessionOffer offer;
offer.session_id = 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) {
channel->disconnect();
return std::string{"the receiver did not answer the session offer"};
}
const SessionAnswer answer = answer_future.get();
if (answer.session_id != session_id) {
channel->disconnect();
return std::string{"session mismatch in the receiver's answer"};
}
if (answer.rtp_endpoint.port == 0) {
channel->disconnect();
return std::string{"the receiver did not provide an RTP port"};
}
// Stream to the negotiated endpoint; an empty address means "the
// address you reached me on".
const Endpoint rtp_endpoint = answer.rtp_endpoint.address.empty()
? Endpoint{signaling_endpoint.address, answer.rtp_endpoint.port}
: answer.rtp_endpoint;
SenderPipelineConfig config;
config.capture_target = target;
config.peer_rtp_endpoint = rtp_endpoint;
config.signaling_server = signaling_endpoint;
config.encoder.bitrate_kbps = bitrate_kbps;
config.session_id = session_id;
auto pipeline = std::make_unique<SenderPipeline>(std::move(config));
if (!pipeline->start()) {
channel->disconnect();
return std::string{"failed to start the sender pipeline"};
}
// PLI feedback over the still-open channel.
channel->on_message([pipeline = pipeline.get(), session_id](const SignalingMessage& message) {
const SessionPli* pli = std::get_if<SessionPli>(&message);
if (pli != nullptr && pli->session_id == session_id) {
pipeline->request_keyframe();
}
});
SenderSession session;
session.channel_ = std::move(channel);
session.pipeline_ = std::move(pipeline);
session.session_id_ = session_id;
session.receiver_ = std::format("{}:{}", rtp_endpoint.address, rtp_endpoint.port);
return session;
}
SenderSession::SenderSession(SenderSession&& other) noexcept
: channel_(std::move(other.channel_)),
pipeline_(std::move(other.pipeline_)),
session_id_(std::move(other.session_id_)),
receiver_(std::move(other.receiver_)),
stopped_(other.stopped_) {}
SenderSession& SenderSession::operator=(SenderSession&& other) noexcept {
stop();
channel_ = std::move(other.channel_);
pipeline_ = std::move(other.pipeline_);
session_id_ = std::move(other.session_id_);
receiver_ = std::move(other.receiver_);
stopped_ = other.stopped_;
return *this;
}
SenderSession::~SenderSession() {
stop();
}
void SenderSession::stop() {
if (stopped_) {
return;
}
stopped_ = true;
// The channel first: disconnecting joins its reader threads, so no PLI
// callback can race the pipeline teardown it points at.
if (channel_ != nullptr) {
channel_->disconnect();
channel_ = nullptr;
}
if (pipeline_ != nullptr) {
pipeline_->stop();
pipeline_ = nullptr;
}
}
} // namespace sc
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "screencast/app/pipeline.h"
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <variant>
namespace sc {
// A running sender session: it negotiated over signaling (keeping the
// channel open for PLI feedback) and drives the sender pipeline. Shared by
// the CLI, the GUI, and the waybar widget's one-click restart.
class SenderSession {
public:
// Blocking: connects, offers, waits for the answer, and starts the
// pipeline — which includes the portal's interactive source picker.
// Returns an error message on failure.
static std::variant<SenderSession, std::string>
start(const Endpoint& signaling_endpoint, int bitrate_kbps, CaptureTarget target);
// Ordering for trying a receiver's addresses: private IPv4 first (LANs,
// most reliable), then public IPv4, ULA, and global IPv6. 6to4 (2002::)
// and link-local addresses last: 6to4 is frequently unreachable between
// LAN peers, and link-local needs a scope id to even route.
static int address_preference(std::string_view host);
SenderSession() = default;
~SenderSession();
SenderSession(SenderSession&& other) noexcept;
SenderSession& operator=(SenderSession&& other) noexcept;
// Graceful stop: withdraws state and closes the signaling channel.
void stop();
[[nodiscard]] const std::string& session_id() const {
return session_id_;
}
[[nodiscard]] const std::string& receiver() const {
return receiver_;
}
private:
std::unique_ptr<class SignalingChannel> channel_;
std::unique_ptr<SenderPipeline> pipeline_;
std::string session_id_;
std::string receiver_;
bool stopped_ = false;
};
} // namespace sc
+140
View File
@@ -0,0 +1,140 @@
#include "state_store.h"
#include <nlohmann/json.hpp>
#include <sys/types.h>
#include <unistd.h>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
namespace sc {
namespace {
namespace fs = std::filesystem;
fs::path runtime_dir() {
const char* xdg = std::getenv("XDG_RUNTIME_DIR");
if (xdg != nullptr && *xdg != '\0') {
return fs::path{xdg} / "screencast";
}
return fs::temp_directory_path() / ("screencast-" + std::to_string(::getuid()));
}
fs::path config_dir() {
const char* xdg = std::getenv("XDG_CONFIG_HOME");
if (xdg != nullptr && *xdg != '\0') {
return fs::path{xdg} / "screencast";
}
const char* home = std::getenv("HOME");
return fs::path{home != nullptr ? home : "."} / ".config" / "screencast";
}
std::optional<std::string> read_file(const fs::path& path) {
std::ifstream file{path, std::ios::binary};
if (!file.is_open()) {
return std::nullopt;
}
std::ostringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
bool write_file(const fs::path& path, std::string_view contents) {
std::error_code error;
fs::create_directories(path.parent_path(), error);
std::ofstream file{path, std::ios::binary | std::ios::trunc};
if (!file.is_open()) {
return false;
}
file.write(contents.data(), static_cast<std::streamsize>(contents.size()));
return file.good();
}
std::int64_t epoch_ms() {
const auto now = std::chrono::system_clock::now().time_since_epoch();
return std::chrono::duration_cast<std::chrono::milliseconds>(now).count();
}
bool process_alive(int pid) {
if (pid <= 0) {
return false;
}
return ::kill(static_cast<pid_t>(pid), 0) == 0;
}
} // namespace
bool write_sender_state(const SenderState& state) {
nlohmann::json json;
json["session_id"] = state.session_id;
json["receiver"] = state.receiver;
json["bitrate_kbps"] = state.bitrate_kbps;
json["pid"] = state.pid;
json["started_epoch_ms"] = state.started_epoch_ms != 0 ? state.started_epoch_ms : epoch_ms();
return write_file(runtime_dir() / "sender.json", json.dump() + "\n");
}
bool remove_sender_state() {
std::error_code error;
const bool removed = fs::remove(runtime_dir() / "sender.json", error);
return removed || !fs::exists(runtime_dir() / "sender.json");
}
std::optional<SenderState> read_sender_state() {
const std::optional<std::string> contents = read_file(runtime_dir() / "sender.json");
if (!contents.has_value()) {
return std::nullopt;
}
const nlohmann::json json = nlohmann::json::parse(*contents, nullptr, /*allow_exceptions=*/false);
if (json.is_discarded() || !json.is_object()) {
return std::nullopt;
}
SenderState state;
state.session_id = json.value("session_id", std::string{});
state.receiver = json.value("receiver", std::string{});
state.bitrate_kbps = json.value("bitrate_kbps", 0);
state.pid = json.value("pid", 0);
state.started_epoch_ms = json.value("started_epoch_ms", std::int64_t{0});
// A state file without a live process is a crash remnant: not streaming.
if (!process_alive(state.pid)) {
(void)remove_sender_state();
return std::nullopt;
}
return state;
}
void write_last_session(const LastSession& last) {
nlohmann::json json;
json["peer"] = last.peer;
json["bitrate_kbps"] = last.bitrate_kbps;
(void)write_file(config_dir() / "last-session.json", json.dump() + "\n");
}
std::optional<LastSession> read_last_session() {
const std::optional<std::string> contents = read_file(config_dir() / "last-session.json");
if (!contents.has_value()) {
return std::nullopt;
}
const nlohmann::json json = nlohmann::json::parse(*contents, nullptr, /*allow_exceptions=*/false);
if (json.is_discarded() || !json.is_object()) {
return std::nullopt;
}
LastSession last;
last.peer = json.value("peer", std::string{});
last.bitrate_kbps = json.value("bitrate_kbps", 0);
if (last.peer.empty() || last.bitrate_kbps <= 0) {
return std::nullopt;
}
return last;
}
} // namespace sc
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
namespace sc {
// Live state of a running sender, written by the pipeline so status
// widgets (waybar) and one-click restarts work for every front-end.
struct SenderState {
std::string session_id;
std::string receiver; // "host:port" of the RTP endpoint
int bitrate_kbps = 0;
int pid = 0;
std::int64_t started_epoch_ms = 0;
};
// The last successfully started session, persisted across reboots so a
// single click can restart streaming without picking anything.
struct LastSession {
std::string peer; // "host" or "host:port" for the signaling endpoint
int bitrate_kbps = 0;
};
bool write_sender_state(const SenderState& state);
bool remove_sender_state();
// Returns nullopt when no sender is running (a stale state file is
// treated as not streaming and is cleaned up).
std::optional<SenderState> read_sender_state();
void write_last_session(const LastSession& last);
std::optional<LastSession> read_last_session();
} // namespace sc
+309
View File
@@ -0,0 +1,309 @@
// GTK4 sender panel: discover receivers on the LAN, pick one, set the
// bitrate, and start/stop streaming. The blocking parts (portal source
// picker, negotiation) run on worker threads so the UI stays responsive.
#include "screencast/network/discovery.h"
#include "sender_session.h"
#include "state_store.h"
#include <gtkmm.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace {
using sc::DiscoveredPeer;
using sc::Endpoint;
struct ReceiverRow {
std::string name;
std::string host;
std::uint16_t signaling_port = 0;
};
class SenderWindow : public Gtk::ApplicationWindow {
public:
SenderWindow() {
set_title("screencast");
set_default_size(440, 400);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 8);
set_child(*box);
box->set_margin(12);
// Receiver list header + refresh.
auto* header = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
box->append(*header);
auto* title = Gtk::make_managed<Gtk::Label>();
title->set_text("Receivers");
title->set_hexpand(true);
title->set_halign(Gtk::Align::START);
header->append(*title);
refresh_button_ = Gtk::make_managed<Gtk::Button>();
refresh_button_->set_label("Refresh");
refresh_button_->signal_clicked().connect(sigc::mem_fun(*this, &SenderWindow::on_refresh));
header->append(*refresh_button_);
scrolled_ = Gtk::make_managed<Gtk::ScrolledWindow>();
scrolled_->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scrolled_->set_vexpand(true);
box->append(*scrolled_);
receiver_list_ = Gtk::make_managed<Gtk::ListBox>();
receiver_list_->set_selection_mode(Gtk::SelectionMode::SINGLE);
receiver_list_->signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { Glib::signal_idle().connect_once([this] { update_sensitivity(); }); });
scrolled_->set_child(*receiver_list_);
// Bitrate.
auto* bitrate_box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
box->append(*bitrate_box);
bitrate_label_ = Gtk::make_managed<Gtk::Label>();
bitrate_label_->set_hexpand(true);
bitrate_label_->set_halign(Gtk::Align::START);
bitrate_box->append(*bitrate_label_);
bitrate_scale_ = Gtk::make_managed<Gtk::Scale>(Gtk::Orientation::HORIZONTAL);
bitrate_scale_->set_range(500.0, 20000.0);
bitrate_scale_->set_value(4000.0);
bitrate_scale_->set_increments(500.0, 1000.0);
bitrate_scale_->set_draw_value(false);
bitrate_scale_->set_hexpand(true);
bitrate_scale_->signal_value_changed().connect([this] { update_bitrate_label(); });
bitrate_box->append(*bitrate_scale_);
update_bitrate_label();
start_button_ = Gtk::make_managed<Gtk::Button>();
start_button_->set_label("Start");
start_button_->signal_clicked().connect(sigc::mem_fun(*this, &SenderWindow::on_start_stop));
box->append(*start_button_);
status_label_ = Gtk::make_managed<Gtk::Label>();
status_label_->set_wrap(true);
status_label_->set_halign(Gtk::Align::START);
status_label_->set_valign(Gtk::Align::START);
status_label_->set_vexpand(true);
box->append(*status_label_);
// Per-second status refresh (elapsed time, liveness heartbeat).
Glib::signal_timeout().connect_seconds(
[this]() -> bool {
update_status();
return true;
},
1);
update_sensitivity();
on_refresh();
}
~SenderWindow() override {
if (worker_.joinable()) {
worker_.join();
}
}
private:
void update_bitrate_label() {
bitrate_label_->set_text(std::format("Bitrate: {} kbps", current_bitrate()));
}
int current_bitrate() const {
return static_cast<int>(bitrate_scale_->get_value());
}
void clear_receiver_rows() {
for (Gtk::Widget* row : receiver_rows_) {
receiver_list_->remove(*row);
}
receiver_rows_.clear();
receivers_.clear();
}
void on_refresh() {
refresh_button_->set_sensitive(false);
status("discovering receivers…");
clear_receiver_rows();
worker_ = std::jthread([this](std::stop_token stop_token) {
std::vector<DiscoveredPeer> peers;
std::mutex mutex;
std::string error;
auto discovery_result = sc::DiscoveryFactory::create_avahi();
if (sc::is_network_error(discovery_result)) {
error = sc::network_error(discovery_result).message;
} else {
auto discovery = std::move(sc::network_value(discovery_result));
if (!discovery->browse([&](const DiscoveredPeer& peer) {
std::lock_guard lock(mutex);
const bool known = std::any_of(peers.begin(), peers.end(), [&](const 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();
}
if (!stop_token.stop_requested()) {
std::this_thread::sleep_for(std::chrono::seconds(3));
}
discovery->stop();
}
Glib::signal_idle().connect_once([this, peers = std::move(peers), error = std::move(error)]() mutable {
receivers_ = group_receivers(std::move(peers));
for (const ReceiverRow& row : receivers_) {
auto* label = Gtk::make_managed<Gtk::Label>();
label->set_text(std::format("{}\n{}", row.name, row.host));
label->set_halign(Gtk::Align::START);
receiver_list_->append(*label);
receiver_rows_.push_back(label);
}
refresh_button_->set_sensitive(true);
update_sensitivity();
status(error.empty() ? (receivers_.empty() ? "no receivers found"
: std::format("{} receiver(s)", receivers_.size()))
: "discovery failed: " + error);
});
});
}
static std::vector<ReceiverRow> group_receivers(std::vector<DiscoveredPeer> peers) {
std::map<std::pair<std::string, std::uint16_t>, std::vector<std::string>> grouped;
for (DiscoveredPeer& peer : peers) {
grouped[{peer.service_name, peer.signaling_port}].push_back(std::move(peer.host));
}
std::vector<ReceiverRow> rows;
for (auto& [key, hosts] : grouped) {
std::sort(hosts.begin(), hosts.end(), [](const std::string& lhs, const std::string& rhs) {
return sc::SenderSession::address_preference(lhs) < sc::SenderSession::address_preference(rhs);
});
ReceiverRow row;
row.name = key.first;
row.host = std::move(hosts.front());
row.signaling_port = key.second;
rows.push_back(std::move(row));
}
return rows;
}
void on_start_stop() {
if (session_.has_value()) {
auto session = std::move(*session_);
session_ = std::nullopt;
start_button_->set_sensitive(false);
status("stopping…");
worker_ = std::jthread([this, session = std::move(session)](std::stop_token) mutable {
session.stop();
Glib::signal_idle().connect_once([this] {
start_button_->set_label("Start");
update_sensitivity();
status("idle");
});
});
return;
}
const int index = selected_index();
if (index < 0) {
status("pick a receiver first");
return;
}
const ReceiverRow& receiver = receivers_[static_cast<std::size_t>(index)];
const Endpoint signaling{receiver.host, receiver.signaling_port};
const int bitrate = current_bitrate();
start_button_->set_sensitive(false);
status(std::format("connecting to {}… (choose a source in the portal dialog)", receiver.name));
worker_ = std::jthread([this, signaling, bitrate](std::stop_token) {
auto result = sc::SenderSession::start(signaling, bitrate, sc::CaptureTargetWholeScreen{});
if (auto* error = std::get_if<std::string>(&result)) {
Glib::signal_idle().connect_once([this, message = *error] {
start_button_->set_label("Start");
update_sensitivity();
status("failed: " + message);
});
return;
}
// sigc++ slots require copyable lambdas; the move-only session
// travels via shared_ptr.
auto session = std::make_shared<sc::SenderSession>(std::move(std::get<sc::SenderSession>(result)));
Glib::signal_idle().connect_once([this, session] {
const std::string receiver_text = session->receiver();
const std::string session_id = session->session_id();
session_ = std::move(*session);
start_button_->set_label("Stop");
update_sensitivity();
status(std::format("streaming to {} (session {})", receiver_text, session_id));
});
});
}
int selected_index() const {
Gtk::ListBoxRow* row = receiver_list_->get_selected_row();
return row != nullptr ? row->get_index() : -1;
}
void update_sensitivity() {
if (session_.has_value()) {
start_button_->set_sensitive(true);
refresh_button_->set_sensitive(false);
return;
}
refresh_button_->set_sensitive(true);
start_button_->set_sensitive(selected_index() >= 0);
}
void update_status() {
if (!session_.has_value()) {
return;
}
const auto state = sc::read_sender_state();
if (state.has_value()) {
const std::int64_t elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count() -
state->started_epoch_ms;
status(std::format("streaming to {}\nsession {}\nelapsed {}m {:02}s",
state->receiver,
state->session_id,
elapsed_ms / 60000,
(elapsed_ms / 1000) % 60));
}
}
void status(const std::string& text) {
status_label_->set_text(text);
}
std::vector<ReceiverRow> receivers_;
std::vector<Gtk::Widget*> receiver_rows_;
std::optional<sc::SenderSession> session_;
std::jthread worker_;
Gtk::Button* refresh_button_ = nullptr;
Gtk::ScrolledWindow* scrolled_ = nullptr;
Gtk::ListBox* receiver_list_ = nullptr;
Gtk::Scale* bitrate_scale_ = nullptr;
Gtk::Label* bitrate_label_ = nullptr;
Gtk::Button* start_button_ = nullptr;
Gtk::Label* status_label_ = nullptr;
};
} // namespace
int main(int argc, char** argv) {
auto app = Gtk::Application::create("io.github.screen_cast.panel");
return app->make_window_and_run<SenderWindow>(argc, argv);
}
+15
View File
@@ -0,0 +1,15 @@
# GTK4 sender panel, behind the `gui` option (default: false). The core
# CLI and tests never need GTK.
dep_gtkmm = dependency('gtkmm-4.0')
screencast_gui_sources = files(
'gui.cpp',
)
executable('screencast-gui',
screencast_gui_sources,
include_directories : [sc_core_inc, include_directories('../app')],
dependencies : [dep_gtkmm, sc_app_core_dep, sc_capture_dep, sc_codec_dep, sc_network_dep,
sc_render_dep],
install : true)
+4
View File
@@ -32,3 +32,7 @@ subdir('network')
subdir('render')
subdir('app')
if get_option('gui')
subdir('gui')
endif